Merge branch 'stable-3.4'

* stable-3.4:
  Set version to 3.4.0-rc4
  Tidy up dev-plugins.txt documentation
  Reftable: Add convert-ref-storage ssh command
  Bazel: Tidy up gerrit_js_bundle rule
  Make srcs in gerrit_js_bundle optional

Change-Id: I07c809934ce344e102d1a8f913b5a627f05ce967
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 c9b2a7f..e750450 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1960,7 +1960,7 @@
   scheme = http
   scheme = anon_http
   scheme = anon_git
-  scheme = repo_download
+  scheme = repo
 ----
 
 The download section configures the allowed download methods.
@@ -2017,12 +2017,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..9d3446e 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -293,9 +293,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 f5fcf95..7db364c 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-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 b2dcfb8..e14df57 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 63601d2..1d96189 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..d3635b3 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"
+    }
   }
 ----
 
@@ -5937,6 +5977,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 +6158,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 +6304,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 +6448,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).
@@ -7040,6 +7058,9 @@
 |`web_links`   |optional|
 Links to the file in external sites as a list of
 link:rest-api-changes.html#web-link-info[WebLinkInfo] 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.
 |==========================
 
 [[diff-info]]
@@ -7773,7 +7794,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 +7827,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 +7859,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 +7894,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 +8056,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/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 06c5ab7..952a1bf 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -335,6 +335,13 @@
 comments on at least one of the sides. Otherwise unchanged files are
 filtered out.
 
+
+- `W` (Rewritten):
++
+The file is rewritten. The status `W` (Rewritten) is returned instead of `M`
+(Modified) if the majority of the lines have been changed so that the new file
+content has a very low similarity with the old file content.
+
 image::images/user-review-ui-change-screen-file-list-modification-type.png[width=800, link="images/user-review-ui-change-screen-file-list-modification-type.png"]
 
 [[rename-or-copy]]
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..6c01d03 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 = "1134ec9b7baee008f1d54f0483049a97e53a57cd3913ec9d6db625549c98395a",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.4.0/rules_nodejs-3.4.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
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/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index b05050d..003df28 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1284,6 +1284,7 @@
     assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
     assertThat(diff.metaB.name).isEqualTo(path);
     assertThat(diff.metaB.webLinks).isNull();
+    assertThat(diff.metaB.editWebLinks).isNull();
 
     assertThat(diff.content).hasSize(1);
     DiffInfo.ContentEntry contentEntry = diff.content.get(0);
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 1b0954e..127f92b 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;
@@ -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..6c6bab0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -32,6 +32,7 @@
 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.server.ExceptionHook;
@@ -75,6 +76,7 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<EditWebLink> editWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -109,6 +111,7 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<EditWebLink> editWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -139,6 +142,7 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
+    this.editWebLinks = editWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -240,6 +244,10 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    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/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/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/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/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/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/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
new file mode 100644
index 0000000..36f7b53
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -0,0 +1,84 @@
+//  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 #blockingExpression()} and {@link #overrideExpression()}
+   * become irrelevant).
+   *
+   * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
+   */
+  public abstract Optional<SubmitRequirementExpression> applicabilityExpression();
+
+  /**
+   * Expression of the condition that blocks the submission of a change. The expression should be
+   * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
+   * fulfilled for the change.
+   */
+  public abstract SubmitRequirementExpression blockingExpression();
+
+  /**
+   * Expression that, if evaluated to true, causes the submit requirement to be fulfilled,
+   * regardless of the blocking expression. This expression should be evaluated for a specific
+   * {@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 setBlockingExpression(SubmitRequirementExpression blockingExpression);
+
+    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..7b31304
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.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.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}.
+ *
+ * <p>TODO: Store the tree representation of the parsed expression internally and throw an exception
+ * upon creation if the expression syntax is invalid.
+ */
+@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 expression();
+}
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..87831e4 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -62,8 +62,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 +155,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/DiffInfo.java b/java/com/google/gerrit/extensions/common/DiffInfo.java
index 2511e96..5a59613 100644
--- a/java/com/google/gerrit/extensions/common/DiffInfo.java
+++ b/java/com/google/gerrit/extensions/common/DiffInfo.java
@@ -52,6 +52,8 @@
     public Integer lines;
     // Links to the file in external sites
     public List<WebLinkInfo> webLinks;
+    // Links to edit the file in external sites
+    public List<WebLinkInfo> editWebLinks;
   }
 
   public static final class ContentEntry {
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/testing/FileMetaSubject.java b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
index 0953bfe..d0212f3 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
@@ -64,4 +64,9 @@
     isNotNull();
     return check("webLinks").that(fileMeta.webLinks);
   }
+
+  public IterableSubject editWebLinks() {
+    isNotNull();
+    return check("editWebLinks").that(fileMeta.editWebLinks);
+  }
 }
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/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 cac716f..032e681 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -224,8 +224,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);
     }
@@ -272,7 +271,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/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/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index d77427a..675c470 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -176,7 +176,8 @@
             .noneMatch(
                 p ->
                     p.getChangeType() == ChangeType.ADDED
-                        || p.getChangeType() == ChangeType.DELETED)) {
+                        || p.getChangeType() == ChangeType.DELETED
+                        || p.getChangeType() == ChangeType.RENAMED)) {
       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 "
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 358ce92..a2ce6fa 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -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;
@@ -108,7 +108,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (message == null || comments.isEmpty()) {
       return;
     }
@@ -128,7 +128,7 @@
           .sendAsync();
     }
     commentAdded.fire(
-        changeNotes.getChange(),
+        ctx.getChangeData(changeNotes),
         ps,
         ctx.getAccount(),
         message.getMessage(),
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index e66e7f5..3b626ea 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -27,6 +27,7 @@
 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;
@@ -56,6 +57,7 @@
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
   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;
@@ -67,6 +69,7 @@
   public WebLinks(
       DynamicSet<PatchSetWebLink> patchSetLinks,
       DynamicSet<ParentWebLink> parentLinks,
+      DynamicSet<EditWebLink> editLinks,
       DynamicSet<FileWebLink> fileLinks,
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
@@ -75,6 +78,7 @@
       DynamicSet<TagWebLink> tagLinks) {
     this.patchSetLinks = patchSetLinks;
     this.parentLinks = parentLinks;
+    this.editLinks = editLinks;
     this.fileLinks = fileLinks;
     this.fileHistoryLinks = fileLogLinks;
     this.diffLinks = diffLinks;
@@ -115,6 +119,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/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 1b3aa96..9d702e6 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -22,8 +22,6 @@
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 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;
@@ -63,18 +61,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>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,38 +145,22 @@
   }
 
   /**
-   * 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;
-
-    static AccountUpdater join(List<AccountUpdater> updaters) {
-      return (accountState, update) -> {
-        for (AccountUpdater updater : updaters) {
-          updater.update(accountState, update);
-        }
-      };
-    }
-
-    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);
-    }
+    void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
   }
 
   private final GitRepositoryManager repoManager;
@@ -193,13 +174,16 @@
   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;
 
+  private static final Runnable DO_NOTHING = () -> {};
+
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -220,11 +204,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 +231,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @VisibleForTesting
@@ -279,29 +264,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 +297,44 @@
    *
    * @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);
+    return execute(
+            repo -> {
+              AccountConfig accountConfig = read(repo, accountId);
               Account account =
                   accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
               AccountState accountState = AccountState.forAccount(account);
-              InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-              updater.update(accountState, updateBuilder);
+              AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+              init.configure(accountState, deltaBuilder);
 
-              InternalAccountUpdate update = updateBuilder.build();
-              accountConfig.setAccountUpdate(update);
+              AccountDelta update = deltaBuilder.build();
+              accountConfig.setAccountDelta(update);
               ExternalIdNotes extIdNotes =
-                  createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
+                  createExternalIdNotes(repo, accountConfig.getExternalIdsRev(), accountId, update);
               CachedPreferences defaultPreferences =
-                  CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+                  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, extIdNotes, defaultPreferences, true);
             })
         .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)
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
       throws LockFailureException, IOException, ConfigInvalidException {
-    return update(message, accountId, AccountUpdater.fromConsumer(update));
+    return update(message, accountId, fromConsumer(update));
   }
 
   /**
@@ -369,40 +344,40 @@
    *
    * @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);
+    return execute(
+        repo -> {
+          AccountConfig accountConfig = read(repo, accountId);
           CachedPreferences defaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
           Optional<AccountState> account =
               AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
           if (!account.isPresent()) {
             return null;
           }
 
-          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-          updater.update(account.get(), updateBuilder);
+          AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+          configureDeltaFromState.configure(account.get(), deltaBuilder);
 
-          InternalAccountUpdate update = updateBuilder.build();
-          accountConfig.setAccountUpdate(update);
+          AccountDelta delta = deltaBuilder.build();
+          accountConfig.setAccountDelta(delta);
           ExternalIdNotes extIdNotes =
-              createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
+              createExternalIdNotes(repo, accountConfig.getExternalIdsRev(), accountId, delta);
           CachedPreferences cachedDefaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
 
-          UpdatedAccount updatedAccounts =
-              new UpdatedAccount(
-                  externalIds, message, accountConfig, extIdNotes, cachedDefaultPreferences);
-          return updatedAccounts;
+          return new UpdatedAccount(
+              message, accountConfig, extIdNotes, cachedDefaultPreferences, false);
         });
   }
 
@@ -413,23 +388,23 @@
     return accountConfig;
   }
 
-  private Optional<AccountState> updateAccount(AccountUpdate accountUpdate)
+  private Optional<AccountState> execute(ExecutableUpdate executableUpdate)
       throws IOException, ConfigInvalidException {
-    return executeAccountUpdate(
+    return executeWithRetry(
         () -> {
           try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-            UpdatedAccount updatedAccount = accountUpdate.update(allUsersRepo);
+            UpdatedAccount updatedAccount = executableUpdate.execute(allUsersRepo);
             if (updatedAccount == null) {
               return Optional.empty();
             }
 
             commit(allUsersRepo, updatedAccount);
-            return Optional.of(updatedAccount.getAccount());
+            return Optional.of(updatedAccount.getAccountState());
           }
         });
   }
 
-  private Optional<AccountState> executeAccountUpdate(Action<Optional<AccountState>> action)
+  private Optional<AccountState> executeWithRetry(Action<Optional<AccountState>> action)
       throws IOException, ConfigInvalidException {
     try {
       return retryHelper.accountUpdate("updateAccount", action).call();
@@ -442,10 +417,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(
@@ -465,41 +437,32 @@
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
-    if (updatedAccount.isCreated()) {
+    if (updatedAccount.created) {
       commitNewAccountConfig(
-          updatedAccount.getMessage(),
-          allUsersRepo,
-          batchRefUpdate,
-          updatedAccount.getAccountConfig());
+          updatedAccount.message, allUsersRepo, batchRefUpdate, updatedAccount.accountConfig);
     } else {
       commitAccountConfig(
-          updatedAccount.getMessage(),
+          updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
-          updatedAccount.getAccountConfig());
+          updatedAccount.accountConfig,
+          false);
     }
 
     commitExternalIdUpdates(
-        updatedAccount.getMessage(),
-        allUsersRepo,
-        batchRefUpdate,
-        updatedAccount.getExternalIdNotes());
+        updatedAccount.message, allUsersRepo, batchRefUpdate, updatedAccount.externalIdNotes);
 
     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(
+        updatedAccount.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)
@@ -522,15 +485,6 @@
       String message,
       Repository allUsersRepo,
       BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, false);
-  }
-
-  private void commitAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
       AccountConfig accountConfig,
       boolean allowEmptyCommit)
       throws IOException {
@@ -566,57 +520,35 @@
   }
 
   @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 ExternalIdNotes externalIdNotes;
+    final CachedPreferences defaultPreferences;
+    final boolean created;
 
-    private boolean created;
-
-    private UpdatedAccount(
-        ExternalIds externalIds,
+    UpdatedAccount(
         String message,
         AccountConfig accountConfig,
-        ExternalIdNotes extIdNotes,
-        CachedPreferences defaultPreferences) {
+        ExternalIdNotes externalIdNotes,
+        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.externalIdNotes = requireNonNull(externalIdNotes);
       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;
+    AccountState getAccountState() throws IOException {
+      return AccountState.fromAccountConfig(
+              externalIds, accountConfig, externalIdNotes, defaultPreferences)
+          .get();
     }
   }
 }
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..5d00ca5 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,121 @@
      *     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.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 +254,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 +274,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 +288,19 @@
   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<>();
 
   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 +328,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;
   }
 
@@ -695,66 +714,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");
@@ -866,12 +825,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 +842,6 @@
         actualExtId.toString());
     noteMap.remove(noteId);
     addFooters(footers, actualExtId);
-    return actualExtId;
   }
 
   /**
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..ab96c6b 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -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);
     }
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/SubmitRequirementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
new file mode 100644
index 0000000..ad015d1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.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.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()))
+        .setBlockingExpression(SubmitRequirementExpression.create(proto.getBlockingExpression()))
+        .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).expression())
+        .setBlockingExpression(submitRequirement.blockingExpression().expression())
+        .setOverrideExpression(
+            submitRequirement.overrideExpression().orElse(emptyExpression).expression())
+        .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..f88e6a9 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -32,7 +32,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;
 
@@ -111,7 +111,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender emailSender =
@@ -127,6 +127,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/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 6f28dad..54ebf40 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -89,9 +89,9 @@
     return Lists.newArrayList(visitorSet);
   }
 
-  void addChangeActions(ChangeInfo to, ChangeNotes notes) {
+  void addChangeActions(ChangeInfo to, ChangeData changeData) {
     List<ActionVisitor> visitors = visitors();
-    to.actions = toActionMap(notes, visitors, copy(visitors, to));
+    to.actions = toActionMap(changeData, visitors, copy(visitors, to));
   }
 
   void addRevisionActions(@Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
@@ -167,7 +167,7 @@
   }
 
   private Map<String, ActionInfo> toActionMap(
-      ChangeNotes notes, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+      ChangeData changeData, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     CurrentUser user = userProvider.get();
     Map<String, ActionInfo> out = new LinkedHashMap<>();
     if (!user.isIdentifiedUser()) {
@@ -175,12 +175,12 @@
     }
 
     Iterable<UiAction.Description> descs =
-        uiActions.from(changeViews, changeResourceFactory.create(notes, user));
+        uiActions.from(changeViews, changeResourceFactory.create(changeData, user));
 
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
-    if (!notes.getChange().isAbandoned()) {
+    if (!changeData.change().isAbandoned()) {
       UiAction.Description descr = new UiAction.Description();
       PrivateInternals_UiActionDescription.setId(descr, "followup");
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index ff8e5c6..1d6fb3c 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,7 +30,6 @@
 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;
@@ -42,9 +40,8 @@
 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..22bbd82 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;
@@ -50,9 +50,9 @@
 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.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 +74,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 +113,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,7 +139,7 @@
   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;
@@ -148,7 +149,7 @@
   private PatchSet patchSet;
   private String pushCert;
   private ProjectState projectState;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
 
   @Inject
   ChangeInserter(
@@ -163,7 +164,7 @@
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       AutoMerger autoMerger,
@@ -181,7 +182,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 +281,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;
   }
@@ -443,8 +444,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);
     }
@@ -475,7 +477,7 @@
   }
 
   @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 +530,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 +547,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 +589,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/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 1e9bc5f3..029f231 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -678,7 +678,7 @@
     }
 
     if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
-      actionJson.addChangeActions(out, cd.notes());
+      actionJson.addChangeActions(out, cd);
     }
 
     if (has(TRACKING_IDS)) {
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 5a1798d..3729b59 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -44,9 +44,10 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
@@ -66,6 +67,8 @@
 
   public interface Factory {
     ChangeResource create(ChangeNotes notes, CurrentUser user);
+
+    ChangeResource create(ChangeData changeData, CurrentUser user);
   }
 
   private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
@@ -77,10 +80,10 @@
   private final StarredChangesUtil starredChangesUtil;
   private final ProjectCache projectCache;
   private final PluginSetContext<ChangeETagComputation> changeETagComputation;
-  private final ChangeNotes notes;
+  private final ChangeData changeData;
   private final CurrentUser user;
 
-  @Inject
+  @AssistedInject
   ChangeResource(
       AccountCache accountCache,
       ApprovalsUtil approvalUtil,
@@ -89,6 +92,7 @@
       StarredChangesUtil starredChangesUtil,
       ProjectCache projectCache,
       PluginSetContext<ChangeETagComputation> changeETagComputation,
+      ChangeData.Factory changeDataFactory,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
     this.accountCache = accountCache;
@@ -98,12 +102,34 @@
     this.starredChangesUtil = starredChangesUtil;
     this.projectCache = projectCache;
     this.changeETagComputation = changeETagComputation;
-    this.notes = notes;
+    this.changeData = changeDataFactory.create(notes);
+    this.user = user;
+  }
+
+  @AssistedInject
+  ChangeResource(
+      AccountCache accountCache,
+      ApprovalsUtil approvalUtil,
+      PatchSetUtil patchSetUtil,
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      ProjectCache projectCache,
+      PluginSetContext<ChangeETagComputation> changeETagComputation,
+      @Assisted ChangeData changeData,
+      @Assisted CurrentUser user) {
+    this.accountCache = accountCache;
+    this.approvalUtil = approvalUtil;
+    this.patchSetUtil = patchSetUtil;
+    this.permissionBackend = permissionBackend;
+    this.starredChangesUtil = starredChangesUtil;
+    this.projectCache = projectCache;
+    this.changeETagComputation = changeETagComputation;
+    this.changeData = changeData;
     this.user = user;
   }
 
   public PermissionBackend.ForChange permissions() {
-    return permissionBackend.user(user).change(notes);
+    return permissionBackend.user(user).change(getNotes());
   }
 
   public CurrentUser getUser() {
@@ -111,7 +137,7 @@
   }
 
   public Change.Id getId() {
-    return notes.getChangeId();
+    return changeData.getId();
   }
 
   /** @return true if {@link #getUser()} is the change's owner. */
@@ -121,7 +147,7 @@
   }
 
   public Change getChange() {
-    return notes.getChange();
+    return changeData.change();
   }
 
   public Project.NameKey getProject() {
@@ -129,7 +155,11 @@
   }
 
   public ChangeNotes getNotes() {
-    return notes;
+    return changeData.notes();
+  }
+
+  public ChangeData getChangeData() {
+    return changeData;
   }
 
   // This includes all information relevant for ETag computation
@@ -153,7 +183,7 @@
       accounts.add(getChange().getAssignee());
     }
     try {
-      patchSetUtil.byChange(notes).stream().map(PatchSet::uploader).forEach(accounts::add);
+      patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
 
       // It's intentional to include the states for *all* reviewers into the ETag computation.
       // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
@@ -162,7 +192,7 @@
       // set of accounts that posted a message is too expensive. However everyone who posts a
       // message is automatically added as reviewer. Hence if we include removed reviewers we can
       // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(notes).all());
+      accounts.addAll(approvalUtil.getReviewers(getNotes()).all());
     } catch (StorageException e) {
       // This ETag will be invalidated if it loads next time.
     }
@@ -178,7 +208,7 @@
 
     ObjectId noteId;
     try {
-      noteId = notes.loadRevision();
+      noteId = getNotes().loadRevision();
     } catch (StorageException e) {
       noteId = null; // This ETag will be invalidated if it loads next time.
     }
@@ -194,7 +224,7 @@
 
     changeETagComputation.runEach(
         c -> {
-          String pluginETag = c.getETag(notes.getProjectName(), notes.getChangeId());
+          String pluginETag = c.getETag(changeData.project(), changeData.getId());
           if (pluginETag != null) {
             h.putString(pluginETag, UTF_8);
           }
@@ -207,8 +237,8 @@
         TraceContext.newTimer(
             "Compute change ETag",
             Metadata.builder()
-                .changeId(notes.getChangeId().get())
-                .projectName(notes.getProjectName().get())
+                .changeId(changeData.getId().get())
+                .projectName(changeData.project().get())
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       if (user.isIdentifiedUser()) {
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..839d7f1 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -22,14 +22,13 @@
 import com.google.gerrit.server.ChangeUtil;
 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.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 {
@@ -73,23 +72,26 @@
   }
 
   @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(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());
       }
-      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..095a19b 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -23,7 +23,6 @@
 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;
@@ -35,6 +34,7 @@
 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.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
@@ -44,9 +44,8 @@
 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;
@@ -56,11 +55,11 @@
 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;
@@ -73,13 +72,13 @@
   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;
   Change currChange;
-  PatchSet currPs;
   Map<String, Short> newApprovals = new HashMap<>();
   Map<String, Short> oldApprovals = new HashMap<>();
 
@@ -95,7 +94,8 @@
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
-      @Assisted AccountState reviewerAccount,
+      AccountCache accountCache,
+      @Assisted Account reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -107,6 +107,7 @@
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.accountCache = accountCache;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -114,15 +115,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,14 +145,14 @@
             ? "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, reviewer.fullName()));
     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("* ")
@@ -166,7 +170,7 @@
     } else {
       msg.append(".");
     }
-    ChangeUpdate update = ctx.getUpdate(currPs.id());
+    ChangeUpdate update = ctx.getUpdate(patchSet.id());
     update.removeReviewer(reviewerId);
 
     changeMessage =
@@ -177,27 +181,32 @@
   }
 
   @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, changeMessage, 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(),
         newApprovals,
@@ -227,14 +236,14 @@
       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.addReviewers(Collections.singleton(reviewer.id()));
     emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
     emailSender.setNotify(notify);
     emailSender.setMessageId(
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..a4f306b
--- /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.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountResolver;
+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/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..df206bd 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -53,7 +53,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;
@@ -298,7 +298,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(changeMessage);
@@ -321,11 +321,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..aee04ec 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;
@@ -270,7 +270,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/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..6f05072 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,20 +248,19 @@
     }
   }
 
-  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;
@@ -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 558bdba..b702440 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -327,7 +327,7 @@
       actionJson.addRevisionActions(
           changeInfo,
           out,
-          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
+          new RevisionResource(changeResourceFactory.create(cd, userProvider.get()), in));
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 411c9b6..063e7e0 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -30,7 +30,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;
@@ -120,7 +120,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     try {
       SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
@@ -134,6 +134,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..3e866fa 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -35,7 +35,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;
@@ -144,10 +144,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..5002ee4 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -30,7 +30,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;
 
@@ -88,9 +88,9 @@
   }
 
   @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());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
index c4a49b0..b4ac203 100644
--- a/java/com/google/gerrit/server/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -24,7 +24,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.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -81,9 +81,9 @@
   }
 
   @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..de81010 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -30,7 +30,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;
@@ -126,8 +126,8 @@
   }
 
   @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
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/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index bb851e2..339b350 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -66,6 +66,7 @@
 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;
@@ -395,6 +396,7 @@
     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/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 8214f03..97cc830 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -27,6 +27,7 @@
 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;
@@ -71,6 +72,7 @@
         }
 
         if (!isNullOrEmpty(type.getFile()) || !isNullOrEmpty(type.getRootTree())) {
+          DynamicSet.bind(binder(), EditWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
         }
 
@@ -253,6 +255,7 @@
   @Singleton
   static class GitwebLinks
       implements BranchWebLink,
+          EditWebLink,
           FileHistoryWebLink,
           FileWebLink,
           PatchSetWebLink,
@@ -327,6 +330,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) {
diff --git a/java/com/google/gerrit/server/diff/DiffInfoCreator.java b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
index c29ffc8..53f0019 100644
--- a/java/com/google/gerrit/server/diff/DiffInfoCreator.java
+++ b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
@@ -156,8 +156,10 @@
         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;
+    ImmutableList<WebLinkInfo> editLinks = webLinksProvider.getEditWebLinks(side.type());
+    result.editWebLinks = editLinks.isEmpty() ? null : editLinks;
     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..d4c7f5b 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 file links associated with the diff side */
   ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType);
+
+  /** Returns edit links associated with the diff side */
+  ImmutableList<WebLinkInfo> getEditWebLinks(DiffSide.Type fileInfoType);
 }
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..272ae65 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -48,7 +48,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 +310,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());
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 78cb013..b6cb30df 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -33,7 +33,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;
@@ -178,7 +178,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!correctBranch) {
       return;
     }
@@ -214,7 +214,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..377718b 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -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;
@@ -1675,7 +1675,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 +1689,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());
@@ -3143,7 +3143,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/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index ce62d7a..6e4f9da 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;
@@ -36,8 +36,8 @@
 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;
@@ -52,10 +52,10 @@
 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 +74,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 +131,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;
@@ -158,7 +159,7 @@
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
@@ -175,7 +176,7 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       Change change,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
@@ -203,7 +204,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 +324,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);
     }
@@ -353,24 +355,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,23 +383,22 @@
           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;
   }
@@ -493,7 +494,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 +507,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) {
@@ -560,7 +562,7 @@
     }
   }
 
-  private void fireApprovalsEvent(Context ctx) {
+  private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
     }
@@ -588,7 +590,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..0ae4021 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -179,6 +179,11 @@
       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")
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/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..af7f1b0 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -65,7 +65,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;
@@ -352,7 +352,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();
@@ -379,7 +379,7 @@
       // 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(),
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/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/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/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/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 4a063a3..5ac5ac7 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;
@@ -123,6 +125,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_BLOCKING_EXPRESSION = "blockingExpression";
+  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 +258,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 +292,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 +524,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 +677,7 @@
     loadBranchOrderSection(rc);
     loadNotifySections(rc);
     loadLabelSections(rc);
+    loadSubmitRequirementSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
     mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
@@ -950,6 +967,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_BLOCKING_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 blocking 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))
+              .setBlockingExpression(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<>();
@@ -1264,6 +1331,7 @@
     savePluginSections(rc, keepGroups);
     groupList.retainUUIDs(keepGroups);
     saveLabelSections(rc);
+    saveSubmitRequirementSections(rc);
     saveCommentLinkSections(rc);
     saveSubscribeSections(rc);
     saveBranchOrderSection(rc);
@@ -1610,6 +1678,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().expression());
+        }
+        rc.setString(
+            SUBMIT_REQUIREMENT,
+            name,
+            KEY_SR_BLOCKING_EXPRESSION,
+            sr.blockingExpression().expression());
+        if (sr.overrideExpression().isPresent()) {
+          rc.setString(
+              SUBMIT_REQUIREMENT,
+              name,
+              KEY_SR_OVERRIDE_EXPRESSION,
+              sr.overrideExpression().get().expression());
+        }
+        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/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/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index d7fc14b..793e4ec 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -318,7 +318,6 @@
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
-  private Boolean merge;
   private Set<String> hashtags;
   private Map<Account.Id, Ref> editsByUser;
   private Set<Account.Id> reviewedBy;
@@ -334,7 +333,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;
@@ -631,7 +630,6 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
-      merge = parentCount > 1;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -987,12 +985,12 @@
 
   @Nullable
   public Boolean isMerge() {
-    if (merge == null) {
+    if (parentCount == null) {
       if (!loadCommitData()) {
         return null;
       }
     }
-    return merge;
+    return parentCount > 1;
   }
 
   public Set<Account.Id> editsByUser() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 4e3edcd..6e2f49c 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -790,7 +790,23 @@
 
   @Operator
   public Predicate<ChangeData> hashtag(String hashtag) {
-    return new HashtagPredicate(hashtag);
+    return new ExactHashtagPredicate(hashtag);
+  }
+
+  @Operator
+  public Predicate<ChangeData> inhashtag(String hashtag) throws QueryParseException {
+    if (hashtag.startsWith("^")) {
+      return new RegexHashtagPredicate(hashtag);
+    }
+    if (hashtag.isEmpty()) {
+      return new ExactHashtagPredicate(hashtag);
+    }
+
+    if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
+      throw new QueryParseException(
+          "'inhashtag' operator is not supported by change index version");
+    }
+    return new FuzzyHashtagPredicate(hashtag, args.index);
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/java/com/google/gerrit/server/query/change/ExactHashtagPredicate.java
similarity index 77%
rename from java/com/google/gerrit/server/query/change/HashtagPredicate.java
rename to java/com/google/gerrit/server/query/change/ExactHashtagPredicate.java
index 1fe4af4..a6526f7 100644
--- a/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ExactHashtagPredicate.java
@@ -14,19 +14,23 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 
-public class HashtagPredicate extends ChangeIndexPredicate {
-  public HashtagPredicate(String hashtag) {
+public class ExactHashtagPredicate extends ChangeIndexPredicate {
+  public ExactHashtagPredicate(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()) {
+  public boolean match(ChangeData cd) {
+    if (Strings.isNullOrEmpty(getValue())) {
+      return cd.hashtags().isEmpty();
+    }
+    for (String hashtag : cd.hashtags()) {
       if (hashtag.equalsIgnoreCase(getValue())) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java
new file mode 100644
index 0000000..35c96ef
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/FuzzyHashtagPredicate.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.FUZZY_HASHTAG;
+
+import com.google.gerrit.server.index.change.ChangeIndex;
+
+public class FuzzyHashtagPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
+
+  public FuzzyHashtagPredicate(String hashtag, ChangeIndex index) {
+    super(FUZZY_HASHTAG, hashtag.toLowerCase());
+    this.index = index;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return cd.hashtags().stream().anyMatch(ht -> ht.toLowerCase().contains(getValue()));
+  }
+
+  @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/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 015b235..1cf875a 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -45,8 +45,8 @@
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 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;
@@ -196,10 +196,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/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/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 842ed2a..e981695 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -34,7 +34,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,8 +113,9 @@
     }
 
     @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/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..433e71f 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -36,9 +36,11 @@
 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.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 +55,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;
@@ -79,6 +82,8 @@
   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,7 +97,9 @@
       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;
@@ -104,6 +111,8 @@
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.attentionSetOpfactory = attentionSetOpFactory;
+    this.currentUserProvider = currentUserProvider;
   }
 
   @Override
@@ -140,6 +149,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();
     }
 
@@ -220,7 +237,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       if (changeMessage == null) {
         return;
       }
@@ -243,7 +260,7 @@
       }
 
       voteDeleted.fire(
-          change,
+          ctx.getChangeData(change),
           ps,
           accountState,
           newApprovals,
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..d48d76a 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -226,18 +226,26 @@
     }
 
     @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks(DiffSide.Type type) {
+      String rev = getSideRev(type);
+      DiffSide side = getDiffSide(type);
+      return webLinks.getEditLinks(projectName.get(), rev, side.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..5191fc8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
@@ -140,5 +140,10 @@
     public ImmutableList<WebLinkInfo> getFileWebLinks(Type fileInfoType) {
       return ImmutableList.of();
     }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks(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/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 8ec394c..e8b4bc9 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -31,7 +31,6 @@
 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;
@@ -287,7 +286,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 +297,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..8c1e655 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;
@@ -55,14 +56,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;
@@ -91,13 +92,13 @@
 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.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 +124,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 +171,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 +197,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 +219,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;
@@ -276,15 +277,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 +314,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 +327,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 +337,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 +351,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 +386,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 +397,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 +439,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);
     }
   }
 
@@ -928,7 +945,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       if (message == null) {
         return;
       }
@@ -968,7 +985,13 @@
         }
       }
       commentAdded.fire(
-          notes.getChange(), ps, user.state(), comment, approvals, oldApprovals, ctx.getWhen());
+          ctx.getChangeData(notes),
+          ps,
+          user.state(),
+          comment,
+          approvals,
+          oldApprovals,
+          ctx.getWhen());
     }
 
     private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
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..17ee92e 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;
@@ -32,8 +32,8 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
 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/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..4723d70 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -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..9fd6d3d 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -22,7 +22,6 @@
 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 +46,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;
@@ -148,7 +147,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       try {
         ReplyToChangeSender emailSender =
             restoredSenderFactory.create(ctx.getProject(), change.getId());
@@ -161,12 +160,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 +180,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..4579bb9 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.server.permissions.ChangePermission.REVERT;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -43,7 +42,6 @@
 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.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -68,14 +66,13 @@
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 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.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CherryPickChange.Result;
 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;
@@ -103,8 +100,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-public class RevertSubmission
-    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
+public class RevertSubmission implements RestModifyView<ChangeResource, RevertInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<InternalChangeQuery> queryProvider;
@@ -519,35 +515,6 @@
     }
   }
 
-  @Override
-  public 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());
-    }
-    return new UiAction.Description()
-        .setLabel("Revert submission")
-        .setTitle(
-            "Revert this change and all changes that have been submitted together with this change")
-        .setVisible(
-            and(
-                and(
-                    change.isMerged()
-                        && change.getSubmissionId() != null
-                        && isChangePartOfSubmission(change.getSubmissionId())
-                        && projectStatePermitsWrite,
-                    permissionBackend
-                        .user(rsrc.getUser())
-                        .ref(change.getDest())
-                        .testCond(CREATE_CHANGE)),
-                permissionBackend.user(rsrc.getUser()).change(rsrc.getNotes()).testCond(REVERT)));
-  }
-
   /**
    * @param submissionId the submission id of the change.
    * @return True if the submission has more than one change, false otherwise.
@@ -614,10 +581,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());
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/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 790b2db..263d1e7 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 ChangeData.Factory changeDataFactory;
@@ -126,6 +118,7 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
+  private final ChangeJson.Factory json;
 
   @Inject
   Submit(
@@ -138,7 +131,8 @@
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeJson.Factory json) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
@@ -173,10 +167,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);
@@ -192,12 +187,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();
@@ -218,7 +212,7 @@
 
       updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
-        return Response.ok(new Output(updatedChange));
+        return updatedChange;
       }
 
       throw new IllegalStateException(
@@ -293,38 +287,27 @@
   }
 
   @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 = changeDataFactory.create(resource.getNotes());
+    ChangeData cd = resource.getChangeResource().getChangeData();
     try {
       MergeOp.checkSubmitRule(cd, false);
     } catch (ResourceConflictException e) {
       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)) {
@@ -474,13 +457,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;
     }
 
@@ -491,8 +472,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/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/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/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/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 69207ac..a957b39 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -48,7 +48,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;
@@ -463,7 +463,7 @@
   }
 
   @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 +518,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 +542,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/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 1c322b2..e93c921 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);
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..ca412da 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;
@@ -919,11 +919,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 +957,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 +976,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 +991,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 +1012,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 +1030,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 +1043,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 +1055,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();
   }
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 517cf89..e17f854 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;
@@ -104,8 +102,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 +115,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 +151,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 +185,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 +202,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;
@@ -1791,9 +1794,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");
@@ -1807,9 +1810,9 @@
     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);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(in.reviewer);
     assertThat(r.error)
@@ -1834,9 +1837,9 @@
     String username = name("new-user");
     Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     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();
@@ -1857,10 +1860,10 @@
     String username = "user@domain.com";
     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();
@@ -1874,7 +1877,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 +1888,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 +1951,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);
@@ -1970,7 +1973,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 +2028,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 +2089,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 +2116,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 +2163,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 +2255,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 +2322,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();
@@ -2417,10 +2460,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();
@@ -2708,7 +2751,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 +2948,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 +3056,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 +3205,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);
     }
@@ -4218,11 +4276,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 +4373,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/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index 9cb9058..c6b57da 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -35,6 +36,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Test;
 
 public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
@@ -58,7 +60,7 @@
     };
   }
 
-  @Inject CustomSubmitRule rule;
+  @Inject private CustomSubmitRule rule;
 
   @Test
   public void submitRequirementIsPropagated() throws Exception {
@@ -170,6 +172,35 @@
     assertThat(result.get(0).changeId).isEqualTo(change.info().changeId);
   }
 
+  @Test
+  public void submitRuleIsInvokedOnlyOnceWhenGettingChangeDetails() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes()
+        .id(changeId)
+        .get(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS);
+
+    // Submit rules are computed freshly, but only once.
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void submitRuleIsNotInvokedWhenQueryingChange() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes()
+        .query(changeId)
+        .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS)
+        .get();
+
+    // Submit rule evaluation results from the change index are reused
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
+  }
+
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
@@ -189,6 +220,7 @@
   @Singleton
   private static class CustomSubmitRule implements SubmitRule {
     private Optional<SubmitRecord.Status> recordStatus = Optional.empty();
+    private AtomicInteger numberOfEvaluations = new AtomicInteger();
 
     public void block(boolean block) {
       this.status(block ? Optional.of(SubmitRecord.Status.NOT_READY) : Optional.empty());
@@ -200,6 +232,7 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      numberOfEvaluations.incrementAndGet();
       if (this.recordStatus.isPresent()) {
         SubmitRecord record = new SubmitRecord();
         record.labels = new ArrayList<>();
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..503ab11 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -405,7 +405,8 @@
   }
 
   @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed() throws Exception {
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+      throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
@@ -420,10 +421,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
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..8b0f61e 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;
@@ -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/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 0b18503..37b4a1c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -30,6 +30,8 @@
 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;
@@ -40,8 +42,11 @@
 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.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -75,6 +80,8 @@
           .collect(joining());
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
+  @Inject private ExtensionRegistry extensionRegistry;
+
   private boolean intraline;
   private boolean useNewDiffCacheListFiles;
   private boolean useNewDiffCacheGetDiff;
@@ -142,6 +149,24 @@
   }
 
   @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.metaB.editWebLinks).hasSize(1);
+      assertThat(info.metaB.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 +2900,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..f0cdc1d 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -152,7 +152,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);
   }
 
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..a76616a 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);
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/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/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/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e35f758..4b33664 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -62,26 +62,12 @@
   }
 
   @Test
-  public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
+  public void mergedChangeActionHasRevert() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     gApi.changes().id(changeId).current().submit();
     Map<String, ActionInfo> actions = getChangeActions(changeId);
     assertThat(actions).containsKey("revert");
-    assertThat(actions).doesNotContainKey("revert_submission");
-  }
-
-  @Test
-  public void changeActionTwoMergedChangesHaveReverts() throws Exception {
-    String changeId1 = createChangeWithTopic().getChangeId();
-    String changeId2 = createChangeWithTopic().getChangeId();
-    gApi.changes().id(changeId1).current().review(ReviewInput.approve());
-    gApi.changes().id(changeId2).current().review(ReviewInput.approve());
-    gApi.changes().id(changeId2).current().submit();
-    Map<String, ActionInfo> actions1 = getChangeActions(changeId1);
-    assertThatMap(actions1).keys().containsAtLeast("revert", "revert_submission");
-    Map<String, ActionInfo> actions2 = getChangeActions(changeId2);
-    assertThatMap(actions2).keys().containsAtLeast("revert", "revert_submission");
   }
 
   @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..5b31fd8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -37,12 +37,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;
@@ -351,7 +352,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 +404,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 +435,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 +449,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 +558,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);
 
@@ -1356,6 +1357,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 +1774,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/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/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/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/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/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/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/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
new file mode 100644
index 0000000..c2e8e0c
--- /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)"))
+          .setBlockingExpression(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/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index aecb5d3..9dad9ae 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,131 @@
   }
 
   @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"
+                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "[submitRequirement \"api-review\"]\n"
+                    + "  description =  Additional review required for API modifications\n"
+                    + "  applicabilityExpression = commit_filepath_contains(\\\"/api/.*\\\")\n"
+                    + "  blockingExpression = label(api-review, +2)\n"
+                    + "  overrideExpression = label(build-cop-override, +1)\n"
+                    + "  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)"))
+                .setBlockingExpression(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/.*\")"))
+                .setBlockingExpression(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"
+                    + "  blockingExpression = 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")
+                .setBlockingExpression(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"
+                    + "  blockingExpression = label(code-review, +2)\n"
+                    + "[submitRequirement \"Code-Review\"]\n"
+                    + "  description = Another code review label\n"
+                    + "  blockingExpression = 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"))
+                .setBlockingExpression(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 readSubmitRequirementNoBlockingExpression() 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 blocking expression."
+                + " Skipping this requirement.");
+  }
+
+  @Test
   public void readConfigLabelOldStyleWithLeadingSpace() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -806,6 +934,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"
+                    + "  blockingExpression = 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..b3ef1ea 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;
@@ -615,7 +615,7 @@
       md.getCommitBuilder().setCommitter(ident);
       new AccountConfig(accountId, allUsers, repo)
           .load()
-          .setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build())
+          .setAccountDelta(AccountDelta.builder().setFullName(newName).build())
           .commit(md);
     }
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index b97d9f2..4e84b3c 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());
@@ -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");
 
@@ -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/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d760003..a822417 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;
@@ -347,9 +347,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/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..f5eafee 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.4.0",
+    "@bazel/terser": "^3.4.0",
+    "@bazel/typescript": "^3.4.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 93e61dc..0022a34 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 93e61dc64debe42eab454e6c268f9c4ee22a78bc
+Subproject commit 0022a34428cf8bfe4feb0935cdd20b0257bfc8a3
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/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index 792f31e..8638295 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -59,6 +59,7 @@
   UNIGNORE = 'unignore',
   UNREVIEWED = 'unreviewed',
   WIP = 'wip',
+  INCLUDED_IN = 'includedIn',
 }
 
 export enum RevisionActions {
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 9cd8cb3..396fd8e 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -53,6 +53,35 @@
 }
 
 /**
+ * Represents a syntax block in a code (e.g. method, function, class, if-else).
+ */
+export 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,35 @@
   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;
+}
+
+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'}
+  | {type: 'zoom-level-changed'; scale: number | 'fit'}
+  | {type: 'follow-mouse-changed'; value: boolean}
+  | {type: 'background-color-changed'; value: string};
+
 export enum GrDiffLineType {
   ADD = 'add',
   BOTH = 'both',
@@ -285,6 +350,47 @@
   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;
 }
diff --git a/polygerrit-ui/wct.conf.js b/polygerrit-ui/app/api/embed.ts
similarity index 60%
rename from polygerrit-ui/wct.conf.js
rename to polygerrit-ui/app/api/embed.ts
index 2096e60..b9918d3 100644
--- a/polygerrit-ui/wct.conf.js
+++ b/polygerrit-ui/app/api/embed.ts
@@ -1,4 +1,9 @@
 /**
+ * @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
  *
@@ -15,20 +20,13 @@
  * limitations under the License.
  */
 
-var path = require('path');
+import {DiffLayer, GrAnnotation} from './diff';
 
-var ret = {
-  suites: ['app/test'],
-  webserver: {
-    pathMappings: []
+declare global {
+  interface Window {
+    grdiff: {
+      GrAnnotation: GrAnnotation;
+      TokenHighlightLayer: {new (): DiffLayer};
+    };
   }
-};
-
-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/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/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-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/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 62cfcb4..2b79fe0 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,
@@ -53,7 +52,7 @@
 } from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
-import {windowLocationReload} from '../../../utils/dom-util';
+import {isShiftPressed, windowLocationReload} from '../../../utils/dom-util';
 import {ScrollMode} from '../../../constants/constants';
 
 const NUMBER_FIXED_COLUMNS = 3;
@@ -439,8 +438,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 +450,7 @@
   _prevPage(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+      (this.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
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..4e52cd2 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} 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,
@@ -274,7 +280,7 @@
   ChangeActions.UNREVIEWED,
 ];
 
-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 +331,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 +387,8 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
+  private readonly changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -525,6 +534,10 @@
       type: ActionType.CHANGE,
       key: ChangeActions.FOLLOW_UP,
     },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.INCLUDED_IN,
+    },
   ];
 
   @property({type: Array})
@@ -809,6 +822,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 +1192,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 +1266,6 @@
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
-      case ChangeActions.REVERT_SUBMISSION:
-        this.showRevertSubmissionDialog();
-        break;
       case ChangeActions.ABANDON:
         this._showActionDialog(this.$.confirmAbandonDialog);
         break;
@@ -1307,6 +1304,9 @@
       case ChangeActions.REBASE_EDIT:
         this._handleRebaseEditTap();
         break;
+      case ChangeActions.INCLUDED_IN:
+        this._handleIncludedInTap();
+        break;
       default:
         this._fireAction(
           this._prependSlash(key),
@@ -1463,18 +1463,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();
@@ -1684,10 +1672,6 @@
     });
   }
 
-  _handleShowRevertSubmissionChangesConfirm() {
-    this._hideAllDialogs();
-  }
-
   _handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
@@ -1746,7 +1730,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', {
@@ -1793,10 +1777,6 @@
     });
   }
 
-  _handleAbandonTap() {
-    this._showActionDialog(this.$.confirmAbandonDialog);
-  }
-
   _handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
@@ -1826,12 +1806,11 @@
   }
 
   _handleDownloadTap() {
-    this.dispatchEvent(
-      new CustomEvent('download-tap', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEvent(this, 'download-tap');
+  }
+
+  _handleIncludedInTap() {
+    fireEvent(this, 'included-tap');
   }
 
   _handleDeleteTap() {
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..ffee84c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -0,0 +1,2573 @@
+/**
+ * @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,
+  createChangeConfig,
+  createChangeMessages,
+  createChangeViewChange,
+  createRevision,
+  createRevisions,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {ChangeStatus, HttpMethod} from '../../../constants/constants';
+import {
+  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', 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: [] 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', 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'] as UIActionInfo)!.__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'] 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', done => {
+      flush(() => {
+        const buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        element.setActionHidden(
+          element.ActionType.REVISION,
+          element.RevisionActions.SUBMIT,
+          true
+        );
+        assert.lengthOf(element._hiddenActions, 1);
+        element.setActionHidden(
+          element.ActionType.REVISION,
+          element.RevisionActions.SUBMIT,
+          true
+        );
+        assert.lengthOf(element._hiddenActions, 1);
+        flush(() => {
+          const buttonEl = element.shadowRoot?.querySelector(
+            '[data-action-key="submit"]'
+          );
+          assert.isNotOk(buttonEl);
+
+          element.setActionHidden(
+            element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT,
+            false
+          );
+          flush(() => {
+            const buttonEl = queryAndAssert(
+              element,
+              '[data-action-key="submit"]'
+            );
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('buttons exist', done => {
+      element._loading = false;
+      flush(() => {
+        const buttonEls = queryAll(element, 'gr-button');
+        const menuItems = element.$.moreActions.items;
+
+        // Total button number is one greater than the number of total actions
+        // due to the existence of the overflow menu trigger.
+        assert.equal(
+          buttonEls!.length + menuItems!.length,
+          element._allActionValues.length + 1
+        );
+        assert.isFalse(element.hidden);
+        done();
+      });
+    });
+
+    test('delete buttons have explicit labels', done => {
+      flush(() => {
+        const deleteItems = element.$.moreActions.items!.filter(item =>
+          item.id!.startsWith('delete')
+        );
+        assert.equal(deleteItems.length, 1);
+        assert.equal(deleteItems[0].name, 'Delete change');
+        done();
+      });
+    });
+
+    test('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', () => {
+      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);
+
+      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' 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);
+    });
+
+    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', done => {
+      sinon.stub(element, '_canSubmitChange').callsFake(() => false);
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      flush(() => {
+        const submitButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="submit"]'
+        );
+        tap(submitButton);
+        assert.equal(fireActionStub.callCount, 0);
+
+        done();
+      });
+    });
+
+    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', done => {
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fetchChangesStub = sinon
+        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      flush(() => {
+        const rebaseButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="rebase"]'
+        );
+        tap(rebaseButton);
+        const rebaseAction = {
+          __key: 'rebase',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Rebase',
+          method: HttpMethod.POST,
+          title: 'Rebase onto tip of branch or parent change',
+        };
+        assert.isTrue(fetchChangesStub.called);
+        element._handleRebaseConfirm(
+          new CustomEvent('', {detail: {base: '1234'}})
+        );
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/rebase',
+          assertUIActionInfo(rebaseAction),
+          true,
+          {base: '1234'},
+        ]);
+        done();
+      });
+    });
+
+    test('rebase change fires reload event', done => {
+      const eventStub = sinon.stub(element, 'dispatchEvent');
+      element._handleResponse(
+        {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
+        new Response()
+      );
+      flush(() => {
+        assert.isTrue(eventStub.called);
+        assert.equal(eventStub.lastCall.args[0].type, 'reload');
+        done();
+      });
+    });
+
+    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 = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledOnce);
+
+      flush(() => {
+        element.$.confirmRebase.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        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 = 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', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.set('disableEdit', true);
+        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', () => {
+        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]'
+          )
+        );
+        flush();
+
+        assert.equal(fireActionStub.lastCall.args[0], '/edit');
+      });
+
+      test('edit patchset is loaded, needs rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.editBasedOnCurrentPatchSet = false;
+        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', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.editBasedOnCurrentPatchSet = true;
+        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', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        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', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        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', done => {
+        element.addEventListener('edit-tap', () => {
+          done();
+        });
+        element.set('editMode', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        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,
+        };
+        flush();
+
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.set('editMode', false);
+        flush();
+
+        const editButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="edit"]'
+        );
+        tap(editButton);
+      });
+    });
+
+    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(done => {
+          stubRestApi('getChanges').returns(Promise.resolve(changes));
+          element._handleCherrypickTap();
+          flush(() => {
+            const radioButtons = queryAll(
+              element.$.confirmCherrypick,
+              "input[name='cherryPickOptions']"
+            );
+            assert.equal(radioButtons.length, 2);
+            tap(radioButtons[1]);
+            flush(() => {
+              done();
+            });
+          });
+        });
+
+        test('cherry pick topic dialog is rendered', done => {
+          const dialog = element.$.confirmCherrypick;
+          flush(() => {
+            const changesTable = queryAndAssert(dialog, 'table');
+            const headers = Array.from(changesTable.querySelectorAll('th'));
+            const expectedHeadings = [
+              '',
+              'Change',
+              'Status',
+              'Subject',
+              'Project',
+              'Progress',
+              '',
+            ];
+            const headings = headers.map(header => header.innerText);
+            assert.equal(headings.length, expectedHeadings.length);
+            for (let i = 0; i < headings.length; i++) {
+              assert.equal(headings[i].trim(), expectedHeadings[i]);
+            }
+            const changeRows = queryAll(changesTable, 'tbody > tr');
+            const change = Array.from(changeRows[0].querySelectorAll('td')).map(
+              e => e.innerText
+            );
+            const expectedChange = [
+              '',
+              '1234567890',
+              'MERGED',
+              'random',
+              'A',
+              'NOT STARTED',
+              '',
+            ];
+            for (let i = 0; i < change.length; i++) {
+              assert.equal(change[i].trim(), expectedChange[i]);
+            }
+            done();
+          });
+        });
+
+        test('changes with duplicate project show an error', done => {
+          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,
+            },
+          ]);
+          flush(() => {
+            assert.equal(
+              error.innerText,
+              'Two changes cannot be of the same' + ' project'
+            );
+            done();
+          });
+        });
+      });
+    });
+
+    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', 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 as CustomEvent).detail.node.getAttribute('data-action-key'),
+          key
+        );
+        element.removeActionButton(key);
+        flush(() => {
+          assert.notOk(query(element, '[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+      flush(() => {
+        tap(queryAndAssert(element, '[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 = 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', done => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        flush(() => {
+          const abandonButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="abandon"]'
+          );
+          tap(abandonButton);
+
+          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+          done();
+        });
+      });
+
+      test('abandon change with no message', done => {
+        flush(() => {
+          const abandonButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="abandon"]'
+          );
+          tap(abandonButton);
+
+          assert.isUndefined(element.$.confirmAbandonDialog.message);
+          done();
+        });
+      });
+
+      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', done => {
+        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');
+        flush(() => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          flush(() => {
+            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+            done();
+          });
+        });
+      });
+
+      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', done => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          flush(() => {
+            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const revertSingleChangeLabel = queryAndAssert(
+              confirmRevertDialog,
+              '.revertSingleChange'
+            ) as HTMLLabelElement;
+            const revertSubmissionLabel = queryAndAssert(
+              confirmRevertDialog,
+              '.revertSubmission'
+            ) as HTMLLabelElement;
+            assert(
+              revertSingleChangeLabel.innerText.trim() ===
+                'Revert single change'
+            );
+            assert(
+              revertSubmissionLabel.innerText.trim() ===
+                'Revert entire submission (2 Changes)'
+            );
+            let expectedMsg =
+              'Revert submission 199 0' +
+              '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' +
+              '\n' +
+              'Reverted Changes:' +
+              '\n' +
+              '1234567890:random' +
+              '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            assert.equal(confirmRevertDialog._message, expectedMsg);
+            const radioInputs = queryAll(
+              confirmRevertDialog,
+              'input[name="revertOptions"]'
+            );
+            tap(radioInputs[0]);
+            flush(() => {
+              expectedMsg =
+                'Revert "random commit message"\n\nThis reverts ' +
+                'commit 2000.\n\nReason' +
+                ' for revert: <INSERT REASONING HERE>\n';
+              assert.equal(confirmRevertDialog._message, expectedMsg);
+              done();
+            });
+          });
+        });
+
+        test('submit fails if message is not edited', done => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = queryAndAssert(
+              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+              '#confirm'
+            );
+            tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('message modification is retained on switching', done => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          flush(() => {
+            const radioInputs = queryAll(
+              confirmRevertDialog,
+              'input[name="revertOptions"]'
+            );
+            const revertSubmissionMsg =
+              'Revert submission 199 0' +
+              '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' +
+              '\n' +
+              'Reverted Changes:' +
+              '\n' +
+              '1234567890:random' +
+              '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            const singleChangeMsg =
+              'Revert "random commit message"\n\nThis reverts ' +
+              'commit 2000.\n\nReason' +
+              ' for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+            const newRevertMsg = revertSubmissionMsg + 'random';
+            const newSingleChangeMsg = singleChangeMsg + 'random';
+            confirmRevertDialog._message = newRevertMsg;
+            tap(radioInputs[0]);
+            flush(() => {
+              assert.equal(confirmRevertDialog._message, singleChangeMsg);
+              confirmRevertDialog._message = newSingleChangeMsg;
+              tap(radioInputs[1]);
+              flush(() => {
+                assert.equal(confirmRevertDialog._message, newRevertMsg);
+                tap(radioInputs[0]);
+                flush(() => {
+                  assert.equal(
+                    confirmRevertDialog._message,
+                    newSingleChangeMsg
+                  );
+                  done();
+                });
+              });
+            });
+          });
+        });
+      });
+
+      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', done => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = queryAndAssert(
+              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+              '#confirm'
+            );
+            tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('confirm revert dialog shows no radio button', done => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const radioInputs = queryAll(
+              confirmRevertDialog,
+              'input[name="revertOptions"]'
+            );
+            assert.equal(radioInputs.length, 0);
+            const msg =
+              'Revert "random commit message"\n\n' +
+              'This reverts commit 2000.\n\nReason ' +
+              'for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, msg);
+            const editedMsg = msg + 'hello';
+            confirmRevertDialog._message += 'hello';
+            const confirmButton = queryAndAssert(
+              queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+              '#confirm'
+            );
+            tap(confirmButton);
+            flush(() => {
+              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+              assert.equal(
+                fireActionStub.getCall(0).args[3].message,
+                editedMsg
+              );
+              done();
+            });
+          });
+        });
+      });
+    });
+
+    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',
+        done => {
+          flush(() => {
+            assert.isNotOk(query(element, '[data-action-key="private"]'));
+            done();
+          });
+        }
+      );
+
+      test('private change', done => {
+        flush(() => {
+          assert.isOk(
+            query(element.$.moreActions, 'span[data-id="private-change"]')
+          );
+          element.setActionOverflow(ActionType.CHANGE, 'private', false);
+          flush();
+          assert.isOk(query(element, '[data-action-key="private"]'));
+          assert.isNotOk(
+            query(element.$.moreActions, 'span[data-id="private-change"]')
+          );
+          done();
+        });
+      });
+    });
+
+    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',
+        done => {
+          flush(() => {
+            assert.isNotOk(
+              query(element, '[data-action-key="private.delete"]')
+            );
+            done();
+          });
+        }
+      );
+
+      test('unmark the private change', done => {
+        flush(() => {
+          assert.isOk(
+            query(
+              element.$.moreActions,
+              'span[data-id="private.delete-change"]'
+            )
+          );
+          element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
+          flush();
+          assert.isOk(query(element, '[data-action-key="private.delete"]'));
+          assert.isNotOk(
+            query(
+              element.$.moreActions,
+              'span[data-id="private.delete-change"]'
+            )
+          );
+          done();
+        });
+      });
+    });
+
+    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', () => {
+        element._handleDeleteTap();
+        assert.isFalse(
+          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+        );
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteDialog'),
+            'gr-button[primary]'
+          )
+        );
+        flush();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', () => {
+        element._handleDeleteTap();
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteDialog'),
+            'gr-button:not([primary])'
+          )
+        );
+        flush();
+        assert.isTrue(
+          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+        );
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('ignore change', () => {
+      setup(done => {
+        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;
+
+        element.reload().then(() => {
+          flush(done);
+        });
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(query(element, '[data-action-key="ignore"]'));
+      });
+
+      test('ignoring change', () => {
+        queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
+        element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
+        flush();
+        queryAndAssert(element, '[data-action-key="ignore"]');
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="ignore-change"]')
+        );
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(done => {
+        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;
+
+        element.reload().then(() => {
+          flush(done);
+        });
+      });
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(query(element, '[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', () => {
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="unignore-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
+        flush();
+        assert.isOk(query(element, '[data-action-key="unignore"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="unignore-change"]')
+        );
+      });
+    });
+
+    suite('reviewed change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const ReviewedAction = {
+          __key: 'reviewed',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.PUT,
+          label: 'Mark reviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          reviewed: ReviewedAction,
+        };
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        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 = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), 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(query(element, '[data-action-key="reviewed"]'));
+      });
+
+      test('reviewing change', () => {
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="reviewed-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'reviewed', false);
+        flush();
+        assert.isOk(query(element, '[data-action-key="reviewed"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="reviewed-change"]')
+        );
+      });
+    });
+
+    suite('unreviewed change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const UnreviewedAction = {
+          __key: 'unreviewed',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.PUT,
+          label: 'Mark unreviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unreviewed: UnreviewedAction,
+        };
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        element.reload().then(() => {
+          flush(done);
+        });
+      });
+
+      test('unreviewed button not outside of the overflow menu', () => {
+        assert.isNotOk(query(element, '[data-action-key="unreviewed"]'));
+      });
+
+      test('unreviewed change', () => {
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="unreviewed-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'unreviewed', false);
+        flush();
+        assert.isOk(query(element, '[data-action-key="unreviewed"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="unreviewed-change"]')
+        );
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(() => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        flush();
+      });
+
+      test('added when can approve', () => {
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+      });
+
+      test('hide quick approve', () => {
+        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();
+        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', () => {
+        element.change!.status = ChangeStatus.MERGED;
+        flush(() => {
+          const approveButton = query(
+            element,
+            "gr-button[data-action-key='review']"
+          );
+          assert.isNotOk(approveButton);
+        });
+      });
+
+      test('not added when already approved', () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when label not permitted', () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('approves when tapped', () => {
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        tap(queryAndAssert(element, "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 as ReviewInput).labels, {foo: 1});
+      });
+
+      test('not added when multiple labels are required without code review', () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('code review shown with multiple missing approval', () => {
+        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'],
+          },
+        };
+        flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isOk(approveButton);
+      });
+
+      test('button label for missing approval', () => {
+        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'],
+          },
+        };
+        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', () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('approving label with a non-max score', () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        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', () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+      });
+
+      test('not added when the user has already approved', () => {
+        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'],
+          },
+        };
+        flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when user owns the change', () => {
+        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'],
+          },
+        };
+        flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(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 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', () => {
+        assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+          element.$.moreActions!.items![0].id,
+          'cherrypick-revision'
+        );
+        element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
+        flush();
+        assert.isOk(query(element, '[data-action-key="cherrypick"]'));
+        assert.notEqual(
+          element.$.moreActions!.items![0].id,
+          'cherrypick-revision'
+        );
+      });
+
+      test('move action to overflow', () => {
+        assert.isOk(query(element, '[data-action-key="submit"]'));
+        element.setActionOverflow(ActionType.REVISION, 'submit', true);
+        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', done => {
+            element
+              ._send(
+                HttpMethod.POST,
+                {message: 'Revert submission'},
+                '/revert_submission',
+                false,
+                cleanup,
+                {} as UIActionInfo
+              )
+              .then(() => {
+                element
+                  ._handleResponse(
+                    {
+                      __key: 'revert_submission',
+                      __type: ActionType.CHANGE,
+                      label: 'l',
+                    },
+                    new Response()
+                  )!
+                  .then(() => {
+                    assert.isTrue(navigateToSearchQueryStub.called);
+                    done();
+                  });
+              });
+          });
+        });
+
+        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', done => {
+            element
+              ._send(
+                HttpMethod.POST,
+                {message: 'Revert submission'},
+                '/revert_submission',
+                false,
+                cleanup,
+                {} as UIActionInfo
+              )
+              .then(() => {
+                element
+                  ._handleResponse(
+                    {
+                      __key: 'revert_submission',
+                      __type: ActionType.CHANGE,
+                      label: 'l',
+                    },
+                    new Response()
+                  )!
+                  .then(() => {
+                    assert.isFalse(showActionDialogStub.called);
+                    assert.isTrue(
+                      navigateToSearchQueryStub.calledWith('topic: T')
+                    );
+                    done();
+                  });
+              });
+          });
+        });
+
+        test('revision action', done => {
+          element
+            ._send(
+              HttpMethod.DELETE,
+              payload,
+              '/endpoint',
+              true,
+              cleanup,
+              {} as UIActionInfo
+            )
+            .then(() => {
+              assert.isFalse(onShowError.called);
+              assert.isTrue(cleanup.calledOnce);
+              assert.isTrue(
+                sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
+              );
+              done();
+            });
+        });
+      });
+
+      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..3d55097 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,7 @@
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
+import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -137,6 +139,9 @@
   @property({type: Object})
   change?: ParsedChangeInfo;
 
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
   @property({type: Object, notify: true})
   labels?: LabelNameToInfoMap;
 
@@ -577,6 +582,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';
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 8c8a886..931579b 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
@@ -165,7 +165,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]]"
@@ -187,7 +194,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)]]"
@@ -197,7 +211,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)]]"
@@ -206,7 +227,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)]]"
@@ -351,6 +379,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-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 6a40aa5..b4d25a6 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
@@ -65,7 +65,6 @@
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  fetchChangeUpdates,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
   PatchSet,
@@ -123,6 +122,7 @@
   DraftInfo,
   isDraftThread,
   isRobot,
+  isUnresolved,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
@@ -169,6 +169,8 @@
 import {Subject} from 'rxjs';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {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 +179,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-';
@@ -511,6 +515,9 @@
   @property({type: String})
   _tabState?: TabState;
 
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
   restApiService = appContext.restApiService;
 
   checksService = appContext.checksService;
@@ -543,6 +550,8 @@
 
   private scrollTask?: DelayedTask;
 
+  private lastStarredTimestamp?: number;
+
   /** @override */
   ready() {
     super.ready();
@@ -639,9 +648,6 @@
     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(
@@ -730,7 +736,8 @@
       activeTabName?: string;
       activeTabIndex?: number;
       scrollIntoView?: boolean;
-    }
+    },
+    src?: string
   ) {
     if (!paperTabs) return;
     const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
@@ -760,7 +767,7 @@
     if (paperTabs.selected !== activeIndex) {
       // paperTabs.selected is undefined during rendering
       if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction('show-tab', {tabName});
+        this.reporting.reportInteraction('show-tab', {tabName, src});
       }
       paperTabs.selected = activeIndex;
     }
@@ -774,11 +781,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 +812,19 @@
     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,
+  _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);
+    this.reporting.reportInteraction('show-tab', {
+      tabName,
+      src: 'paper-tab-click',
     });
-    if (activeTabName) {
-      this._activeTabs = [this._activeTabs[0], activeTabName];
-    }
   }
 
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
@@ -893,6 +902,13 @@
     return false;
   }
 
+  _computeShowUnresolved(threads?: CommentThread[]) {
+    // If all threads are resolved and the Comments Tab is opened then show
+    // all threads instead
+    if (!threads?.length) return true;
+    return threads.filter(thread => isUnresolved(thread)).length > 0;
+  }
+
   _robotCommentCountPerPatchSet(threads: CommentThread[]) {
     return threads.reduce((robotCommentCountMap, thread) => {
       const comments = thread.comments;
@@ -1269,13 +1285,6 @@
         },
       })
     );
-    this._setActiveSecondaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: SecondaryTab.CHANGE_LOG,
-        },
-      })
-    );
   }
 
   _sendShowChangeEvent() {
@@ -1495,17 +1504,6 @@
     return GerritNav.getUrlForChange(change);
   }
 
-  _computeShowCommitInfo(
-    changeStatuses: string[],
-    current_revision: RevisionInfo
-  ) {
-    return (
-      changeStatuses.length === 1 &&
-      changeStatuses[0] === 'Merged' &&
-      current_revision
-    );
-  }
-
   _computeReplyButtonLabel(
     changeRecord?: ElementPropertyDeepChange<
       GrChangeView,
@@ -1836,6 +1834,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 +1904,7 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.computeRevertSubmitted(change);
         this.changeService.updateChange(change);
         if (
           !this._patchRange ||
@@ -2258,10 +2282,6 @@
     );
   }
 
-  _computeReplyDisabled() {
-    return false;
-  }
-
   _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
     return `Change ${changeNum}`;
   }
@@ -2298,7 +2318,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;
@@ -2498,6 +2518,16 @@
   }
 
   _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+    if (e.detail.starred) {
+      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 0a8bf86..6025268 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
@@ -113,8 +113,7 @@
       --collapsed-max-height: 300px;
     }
     .changeStatuses,
-    .commitActions,
-    .statusText {
+    .commitActions {
       align-items: center;
       display: flex;
     }
@@ -323,6 +322,8 @@
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
               <gr-change-status
+                change="[[_change]]"
+                reverted-change="[[revertedChange]]"
                 max-width="100"
                 status="[[status]]"
               ></gr-change-status>
@@ -372,6 +373,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 +386,7 @@
           <gr-change-metadata
             id="metadata"
             change="{{_change}}"
+            reverted-change="[[revertedChange]]"
             account="[[_account]]"
             revision="[[_selectedRevision]]"
             commit-info="[[_commitInfo]]"
@@ -459,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]]"
+        >Files</paper-tab
+      >
+      <paper-tab
+        on-click="_onPaperTabClick"
         data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
         class="commentThreads"
       >
@@ -472,7 +480,9 @@
         >
       </paper-tab>
       <template is="dom-if" if="[[_showChecksTab]]">
-        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
+        <paper-tab
+          data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          on-click="_onPaperTabClick"
           >Checks</paper-tab
         >
       </template>
@@ -490,7 +500,10 @@
           </gr-endpoint-decorator>
         </paper-tab>
       </template>
-      <paper-tab data-name$="[[_constants.PrimaryTab.FINDINGS]]">
+      <paper-tab
+        data-name$="[[_constants.PrimaryTab.FINDINGS]]"
+        on-click="_onPaperTabClick"
+      >
         Findings
       </paper-tab>
     </paper-tabs>
@@ -521,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"
         >
@@ -558,7 +570,7 @@
           logged-in="[[_loggedIn]]"
           comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
-          unresolved-only
+          unresolved-only="[[_computeShowUnresolved(_commentThreads)]]"
           show-comment-context
         ></gr-thread-list>
       </template>
@@ -620,7 +632,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"
@@ -630,24 +642,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..3650d01 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');
@@ -1265,6 +1268,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(
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-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-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..532097b 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,12 +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';
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-file-list-header': GrFileListHeader;
@@ -63,7 +60,7 @@
 }
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends KeyboardShortcutMixin(PolymerElement) {
+export class GrFileListHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -81,10 +78,6 @@
    */
 
   /**
-   * @event open-included-in-dialog
-   */
-
-  /**
    * @event open-download-dialog
    */
 
@@ -185,13 +178,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 +194,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 +213,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..69be729 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;
     }
@@ -182,11 +177,6 @@
           >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)]]"
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..ca16ec0 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,
@@ -614,52 +618,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 +640,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 +655,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`;
   }
@@ -924,7 +895,7 @@
       this._displayLine = true;
     } else {
       // Down key
-      if (this.getKeyboardEvent(e).keyCode === 40) {
+      if (getKeyboardEvent(e).keyCode === 40) {
         return;
       }
       e.preventDefault();
@@ -944,7 +915,7 @@
       this._displayLine = true;
     } else {
       // Up key
-      if (this.getKeyboardEvent(e).keyCode === 38) {
+      if (getKeyboardEvent(e).keyCode === 38) {
         return;
       }
       e.preventDefault();
@@ -964,10 +935,7 @@
 
   _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 +945,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,15 +970,14 @@
   _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)) {
+    if (isShiftPressed(e)) {
       this.$.diffCursor.moveToNextCommentThread();
     } else {
       this.$.diffCursor.moveToNextChunk();
@@ -1023,15 +987,14 @@
   _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)) {
+    if (isShiftPressed(e)) {
       this.$.diffCursor.moveToPreviousCommentThread();
     } else {
       this.$.diffCursor.moveToPreviousChunk();
@@ -1100,14 +1063,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;
   }
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..40bd5bc 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">
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..dcc2e46 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', () => {
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..26af2a2 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.accountsInMessage,' +
+      ' message.tag)',
   })
   _messageContentExpanded = '';
 
   @property({
     type: String,
     computed:
-      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      '_computeMessageContentCollapsed(message.message,' +
+      ' message.accountsInMessage,' +
+      ' 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_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-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 2cabf90..bf61aa5 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,7 +38,6 @@
   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 {getDisplayName} from '../../../utils/display-name-util';
@@ -226,6 +225,8 @@
 
   flagsService = appContext.flagsService;
 
+  changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeInfo;
 
@@ -436,7 +437,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;
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..adf4f71 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
@@ -41,6 +41,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 {
@@ -261,32 +262,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 +332,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..0a709e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -0,0 +1,477 @@
+/**
+ * @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,
+  createServerInfo,
+} 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();
+    element.serverConfig = createServerInfo();
+
+    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_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..1fe00ff 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);
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..91f119e 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');
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-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..5ba3606 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
@@ -97,8 +97,13 @@
       return section;
     }
 
+    let diffInfo;
+    let renderPrefs;
+
     setup(() => {
-      builder = new GrDiffBuilder({content: []}, prefs, null, []);
+      diffInfo = {content: []};
+      renderPrefs = {};
+      builder = new GrDiffBuilder(diffInfo, prefs, null, [], renderPrefs);
     });
 
     test('no +10 buttons for 10 or less lines', () => {
@@ -149,6 +154,70 @@
       assert.include([...buttons[0].classList.values()], 'aboveButton');
       assert.include([...buttons[1].classList.values()], 'aboveButton');
     });
+
+    suite('with block expansion', () => {
+      setup(() => {
+        builder._numLinesLeft = 50;
+        renderPrefs.use_block_expansion = true;
+        diffInfo.meta_b = {
+          syntax_tree: [],
+        };
+      });
+
+      test('context control with block expansion at the top', () => {
+        const section = createContextSectionForGroups({offset: 0, count: 20});
+
+        const fullExpansionButtons = section
+            .querySelectorAll('.fullExpansion gr-button');
+        const partialExpansionButtons = section
+            .querySelectorAll('.partialExpansion gr-button');
+        const blockExpansionButtons = section
+            .querySelectorAll('.blockExpansion gr-button');
+        assert.equal(fullExpansionButtons.length, 1);
+        assert.equal(partialExpansionButtons.length, 1);
+        assert.equal(blockExpansionButtons.length, 1);
+        assert.equal(blockExpansionButtons[0].textContent, '+Block');
+        assert.include([...blockExpansionButtons[0].classList.values()],
+            'belowButton');
+      });
+
+      test('context control in the middle', () => {
+        const section = createContextSectionForGroups({offset: 10, count: 20});
+
+        const fullExpansionButtons = section
+            .querySelectorAll('.fullExpansion gr-button');
+        const partialExpansionButtons = section
+            .querySelectorAll('.partialExpansion gr-button');
+        const blockExpansionButtons = section
+            .querySelectorAll('.blockExpansion gr-button');
+        assert.equal(fullExpansionButtons.length, 1);
+        assert.equal(partialExpansionButtons.length, 2);
+        assert.equal(blockExpansionButtons.length, 2);
+        assert.equal(blockExpansionButtons[0].textContent, '+Block');
+        assert.equal(blockExpansionButtons[1].textContent, '+Block');
+        assert.include([...blockExpansionButtons[0].classList.values()],
+            'aboveButton');
+        assert.include([...blockExpansionButtons[1].classList.values()],
+            'belowButton');
+      });
+
+      test('context control at the bottom', () => {
+        const section = createContextSectionForGroups({offset: 30, count: 20});
+
+        const fullExpansionButtons = section
+            .querySelectorAll('.fullExpansion gr-button');
+        const partialExpansionButtons = section
+            .querySelectorAll('.partialExpansion gr-button');
+        const blockExpansionButtons = section
+            .querySelectorAll('.blockExpansion gr-button');
+        assert.equal(fullExpansionButtons.length, 1);
+        assert.equal(partialExpansionButtons.length, 1);
+        assert.equal(blockExpansionButtons.length, 1);
+        assert.equal(blockExpansionButtons[0].textContent, '+Block');
+        assert.include([...blockExpansionButtons[0].classList.values()],
+            'aboveButton');
+      });
+    });
   });
 
   test('newlines 1', () => {
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.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 874f572..d5e6ecd 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,8 +16,11 @@
  */
 import {
   ContentLoadNeededEventDetail,
+  ContextButtonType,
+  DiffContextExpandedExternalDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
+  SyntaxBlock,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
@@ -57,13 +60,8 @@
 
 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;
@@ -76,6 +74,19 @@
   }
 }
 
+function findMostNestedContainingBlock(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock | undefined {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  const containingChildBlock = containingBlock
+    ? findMostNestedContainingBlock(lineNum, containingBlock?.children)
+    : undefined;
+  return containingChildBlock || containingBlock;
+}
+
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -158,13 +169,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;
@@ -316,6 +320,7 @@
     );
   }
 
+  // TODO(renanoliveira): Move context controls to polymer component (or at least a separate class).
   _createContextControls(
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
@@ -324,9 +329,9 @@
     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 rightStart = contextGroups[0].lineRange.right.start_line;
+    const rightEnd =
+      contextGroups[contextGroups.length - 1].lineRange.right.end_line;
 
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
@@ -345,7 +350,8 @@
         contextGroups,
         showAbove,
         showBelow,
-        numLines
+        rightStart,
+        rightEnd
       )
     );
     if (showBelow) {
@@ -364,8 +370,12 @@
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
     showBelow: boolean,
-    numLines: number
+    rightStart: number,
+    rightEnd: number
   ): HTMLElement {
+    const numLines = rightEnd - rightStart + 1;
+    if (numLines === 0) console.error('context group without lines');
+
     const row = this._createElement('tr', 'contextDivider');
     if (!(showAbove && showBelow)) {
       row.classList.add('collapsed');
@@ -374,13 +384,55 @@
     const element = this._createElement('td', 'dividerCell');
     row.appendChild(element);
 
-    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
+    const showAllContainer = this._createExpandAllButtonContainer(
+      section,
+      contextGroups,
+      showAbove,
+      showBelow,
+      numLines
+    );
     element.appendChild(showAllContainer);
 
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+    if (showPartialLinks) {
+      const partialExpansionContainer = this._createPartialExpansionButtons(
+        section,
+        contextGroups,
+        showAbove,
+        showBelow,
+        numLines
+      );
+      if (partialExpansionContainer) {
+        element.appendChild(partialExpansionContainer);
+      }
+      const blockExpansionContainer = this._createBlockExpansionButtons(
+        section,
+        contextGroups,
+        showAbove,
+        showBelow,
+        rightStart,
+        rightEnd,
+        numLines
+      );
+      if (blockExpansionContainer) {
+        element.appendChild(blockExpansionContainer);
+      }
+    }
+    return row;
+  }
+
+  private _createExpandAllButtonContainer(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ) {
     const showAllButton = this._createContextButton(
       ContextButtonType.ALL,
       section,
       contextGroups,
+      numLines,
       numLines
     );
     showAllButton.classList.add(
@@ -390,35 +442,131 @@
         ? 'aboveButton'
         : 'belowButton'
     );
+    const showAllContainer = this._createElement(
+      'div',
+      'aboveBelowButtons fullExpansion'
+    );
     showAllContainer.appendChild(showAllButton);
+    return showAllContainer;
+  }
 
-    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
-          )
-        );
-      }
-      element.appendChild(container);
+  private _createPartialExpansionButtons(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ) {
+    let aboveButton;
+    let belowButton;
+    if (showAbove) {
+      aboveButton = this._createContextButton(
+        ContextButtonType.ABOVE,
+        section,
+        contextGroups,
+        numLines,
+        PARTIAL_CONTEXT_AMOUNT
+      );
     }
+    if (showBelow) {
+      belowButton = this._createContextButton(
+        ContextButtonType.BELOW,
+        section,
+        contextGroups,
+        numLines,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (aboveButton || belowButton) {
+      const partialExpansionContainer = this._createElement(
+        'div',
+        'aboveBelowButtons partialExpansion'
+      );
+      aboveButton && partialExpansionContainer.appendChild(aboveButton);
+      belowButton && partialExpansionContainer.appendChild(belowButton);
+      return partialExpansionContainer;
+    }
+    return undefined;
+  }
 
-    return row;
+  private _createBlockExpansionButtons(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    rightStart: number,
+    rightEnd: number,
+    numLines: number
+  ) {
+    if (!this._renderPrefs?.use_block_expansion) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    const rightSyntaxTree = this._diff.meta_b.syntax_tree;
+    if (showAbove) {
+      aboveBlockButton = this._createBlockButton(
+        section,
+        contextGroups,
+        ContextButtonType.BLOCK_ABOVE,
+        numLines,
+        rightStart - 1,
+        rightSyntaxTree
+      );
+    }
+    if (showBelow) {
+      belowBlockButton = this._createBlockButton(
+        section,
+        contextGroups,
+        ContextButtonType.BLOCK_BELOW,
+        numLines,
+        rightEnd + 1,
+        rightSyntaxTree
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      const blockExpansionContainer = this._createElement(
+        'div',
+        'blockExpansion aboveBelowButtons'
+      );
+      aboveBlockButton && blockExpansionContainer.appendChild(aboveBlockButton);
+      belowBlockButton && blockExpansionContainer.appendChild(belowBlockButton);
+      return blockExpansionContainer;
+    }
+    return undefined;
+  }
+
+  private _createBlockButton(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number,
+    syntaxTree?: SyntaxBlock[]
+  ) {
+    const containingBlock = findMostNestedContainingBlock(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    if (containingBlock) {
+      const {range} = containingBlock;
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    return this._createContextButton(
+      buttonType,
+      section,
+      contextGroups,
+      numLines,
+      linesToExpand
+    );
   }
 
   /**
@@ -453,9 +601,9 @@
     type: ContextButtonType,
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
-    numLines: number
+    numLines: number,
+    linesToExpand: number
   ) {
-    const context = PARTIAL_CONTEXT_AMOUNT;
     const button = this._createElement('gr-button', 'showContext');
     button.classList.add('contextControlButton');
     button.setAttribute('link', 'true');
@@ -464,11 +612,11 @@
     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')}`;
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
       button.setAttribute(
         'aria-label',
-        `Show ${pluralize(numLines, 'common line')}`
+        `Show ${pluralize(linesToExpand, 'common line')}`
       );
       requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
       if (requiresLoad) {
@@ -476,22 +624,32 @@
         text += ' (too large)';
       }
       groups.push(...contextGroups);
-    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      groups = hideInContextControl(contextGroups, context, numLines);
-      text = `+${context}`;
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(contextGroups, linesToExpand, numLines);
+      text = `+${linesToExpand}`;
       button.classList.add('aboveButton');
       button.setAttribute(
         'aria-label',
-        `Show ${pluralize(context, 'line')} above`
+        `Show ${pluralize(linesToExpand, 'line')} above`
       );
-    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
-      groups = hideInContextControl(contextGroups, 0, numLines - context);
-      text = `+${context}`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(contextGroups, 0, numLines - linesToExpand);
+      text = `+${linesToExpand}`;
       button.classList.add('belowButton');
       button.setAttribute(
         'aria-label',
-        `Show ${pluralize(context, 'line')} below`
+        `Show ${pluralize(linesToExpand, 'line')} below`
       );
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(contextGroups, linesToExpand, numLines);
+      text = '+Block';
+      button.classList.add('aboveButton');
+      button.setAttribute('aria-label', 'Show block above');
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(contextGroups, 0, numLines - linesToExpand);
+      text = '+Block';
+      button.classList.add('belowButton');
+      button.setAttribute('aria-label', 'Show block below');
     }
     const textSpan = this._createElement('span', 'showContext');
     textSpan.textContent = text;
@@ -523,6 +681,8 @@
           groups,
           section,
           numLines,
+          buttonType: type,
+          expandedLines: linesToExpand,
         });
       });
     }
@@ -612,14 +772,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);
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..1d9a44b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -0,0 +1,252 @@
+/**
+ * @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 UPDATE_TOKEN_TASK_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);
+      },
+      UPDATE_TOKEN_TASK_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-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 76e02f0..ca003f3 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,7 +28,13 @@
 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';
 
 interface SidedRange {
@@ -356,11 +362,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;
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..6f34067 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
@@ -80,6 +80,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.';
@@ -400,8 +401,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() {
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..1d6bc95 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';
@@ -36,10 +38,21 @@
 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.
@@ -63,6 +76,12 @@
 
   @internalProperty() protected checkerboardSelected = true;
 
+  @internalProperty() protected backgroundColor = '';
+
+  @internalProperty() protected automaticBlink = false;
+
+  @internalProperty() protected automaticBlinkShown = false;
+
   @internalProperty() protected zoomedImageStyle: StyleInfo = {};
 
   @query('.imageArea') protected imageArea!: HTMLDivElement;
@@ -71,6 +90,8 @@
 
   @query('#source-image') protected sourceImage!: HTMLImageElement;
 
+  @query('#automatic-blink-button') protected automaticBlinkButton?: Element;
+
   private imageSize: Dimensions = {width: 0, height: 0};
 
   @internalProperty()
@@ -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,7 @@
       padding: var(--spacing-m);
       font: var(--image-diff-button-font);
       text-transform: var(--image-diff-button-text-transform, uppercase);
+      outline: 1px solid transparent;
     }
     paper-button[unelevated] {
       color: var(--primary-button-text-color);
@@ -197,12 +250,27 @@
     }
     #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);
@@ -217,10 +285,80 @@
       margin: 0 var(--spacing-xl);
     }
     #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 +366,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 +394,8 @@
         >
           Base
         </paper-button>
+        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.toggleImage}">
+        </paper-fab>
         <paper-button
           class="right"
           ?unelevated=${!this.baseSelected}
@@ -273,7 +416,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 +457,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,6 +490,17 @@
       ></div>
     `;
 
+    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 be
     // wrapped in a <custom-style>.
     const customStyle = html`
@@ -335,13 +509,15 @@
             paper-button.left {
               --paper-button: {
                 border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
+                border-width: 1px;
+                border-style: solid;
+                border-color: var(--primary-button-background-color);
               }
             }
             paper-button.left[outlined] {
               --paper-button: {
                 border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
+                border-width: 1px;
                 border-style: solid;
                 border-color: var(--primary-button-background-color);
               }
@@ -349,13 +525,15 @@
             paper-button.right {
               --paper-button: {
                 border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
+                border-width: 1px;
+                border-style: solid;
+                border-color: var(--primary-button-background-color);
               }
             }
             paper-button.right[outlined] {
               --paper-button: {
                 border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
+                border-width: 1px;
                 border-style: solid;
                 border-color: var(--primary-button-background-color);
               }
@@ -384,7 +562,11 @@
 
     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 +586,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,11 +621,17 @@
   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() {
@@ -452,21 +640,87 @@
     }
   }
 
+  toggleAutomaticBlink() {
+    this.automaticBlink = !this.automaticBlink;
+    if (this.automaticBlink) {
+      this.toggleImage();
+      this.setBlinkInterval();
+    } else {
+      if (this.automaticBlinkTimer) {
+        clearInterval(this.automaticBlinkTimer);
+        this.automaticBlinkTimer = undefined;
+      }
+    }
+  }
+
+  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) {
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..d7b6916 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
@@ -25,8 +25,9 @@
   query,
 } 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
@@ -50,8 +51,6 @@
 
   @internalProperty() protected frameStyle: StyleInfo = {};
 
-  @internalProperty() protected overlayStyle: StyleInfo = {};
-
   @internalProperty() 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/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..8ead181 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
@@ -98,7 +98,7 @@
 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';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
@@ -429,10 +429,6 @@
     return this.restApiService.getPreferences();
   }
 
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
   _handleReviewedChange(e: Event) {
     this._setReviewed(
       ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
@@ -593,7 +589,7 @@
   _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 +600,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;
 
@@ -1215,17 +1211,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 +1515,6 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getDiffDrafts() {
-    assertIsDefined(this._changeNum, '_changeNum');
-
-    return this.restApiService.getDiffDrafts(this._changeNum);
-  }
-
   _computeCommentSkips(
     commentMap?: CommentMap,
     fileList?: string[],
@@ -1812,10 +1791,6 @@
     return loggedIn && changeIsOpen(changeChangeRecord.base);
   }
 
-  _computeIsLoggedIn(loggedIn: boolean) {
-    return loggedIn ? true : false;
-  }
-
   /**
    * Wrapper for using in the element template and computed properties
    */
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..0412779 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;
     }
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..96fbd8d 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
@@ -43,6 +43,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');
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 41dd159..3b76698 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,
@@ -546,7 +547,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 a93d5b5..285c942 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
@@ -109,6 +109,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;
@@ -605,6 +613,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/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-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-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-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..71c1add 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
@@ -19,13 +19,18 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-status_html';
 import {customElement, property} from '@polymer/decorators';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {ChangeInfo} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
 
-enum ChangeStates {
+export enum ChangeStates {
   MERGED = 'Merged',
   ABANDONED = 'Abandoned',
   MERGE_CONFLICT = 'Merge Conflict',
   WIP = 'WIP',
   PRIVATE = 'Private',
+  REVERT_CREATED = 'Revert Created',
+  REVERT_SUBMITTED = 'Revert Submitted',
 }
 
 const WIP_TOOLTIP =
@@ -51,12 +56,18 @@
   @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;
+
   _computeStatusString(status: ChangeStates) {
     if (status === ChangeStates.WIP && !this.flat) {
       return 'Work in Progress';
@@ -68,6 +79,17 @@
     return str ? str.toLowerCase().replace(/\s/g, '-') : '';
   }
 
+  hasStatusLink(revertedChange?: ChangeInfo) {
+    return revertedChange !== undefined;
+  }
+
+  getStatusLink(revertedChange?: ChangeInfo) {
+    return (
+      revertedChange &&
+      GerritNav.getUrlForSearchQuery(`${revertedChange._number}`)
+    );
+  }
+
   _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..3d227a9 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);
@@ -70,8 +81,17 @@
     title="[[tooltipText]]"
     max-width="40em"
   >
-    <div class="chip" aria-label$="Label: [[status]]">
-      [[_computeStatusString(status)]]
-    </div>
+    <template is="dom-if" if="[[!!hasStatusLink(revertedChange)]]">
+      <a class="status-link" href="[[getStatusLink(revertedChange)]]">
+        <div class="chip" aria-label$="Label: [[status]]">
+          [[_computeStatusString(status)]]
+        </div>
+      </a>
+    </template>
+    <template is="dom-if" if="[[!hasStatusLink(revertedChange)]]">
+      <div class="chip" aria-label$="Label: [[status]]">
+        [[_computeStatusString(status)]]
+      </div>
+    </template>
   </gr-tooltip-content>
 `;
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
index 16fc664..4ef97c9 100644
--- 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
@@ -37,6 +37,7 @@
 
   test('WIP', () => {
     element.status = 'WIP';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, 'Work in Progress');
     assert.equal(element.tooltipText, WIP_TOOLTIP);
@@ -46,6 +47,7 @@
   test('WIP flat', () => {
     element.flat = true;
     element.status = 'WIP';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, 'WIP');
     assert.isDefined(element.tooltipText);
@@ -55,6 +57,7 @@
 
   test('merged', () => {
     element.status = 'Merged';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, element.status);
     assert.equal(element.tooltipText, '');
@@ -63,6 +66,7 @@
 
   test('abandoned', () => {
     element.status = 'Abandoned';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, element.status);
     assert.equal(element.tooltipText, '');
@@ -71,6 +75,7 @@
 
   test('merge conflict', () => {
     element.status = 'Merge Conflict';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, element.status);
     assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
@@ -79,6 +84,7 @@
 
   test('private', () => {
     element.status = 'Private';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, element.status);
     assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
@@ -87,6 +93,7 @@
 
   test('active', () => {
     element.status = 'Active';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, element.status);
     assert.equal(element.tooltipText, '');
@@ -95,6 +102,7 @@
 
   test('ready to submit', () => {
     element.status = 'Ready to submit';
+    flush();
     assert.equal(element.shadowRoot
         .querySelector('.chip').innerText, element.status);
     assert.equal(element.tooltipText, '');
@@ -103,10 +111,12 @@
 
   test('updating status removes the previous class', () => {
     element.status = 'Private';
+    flush();
     assert.isTrue(element.classList.contains('private'));
     assert.isFalse(element.classList.contains('wip'));
 
     element.status = 'WIP';
+    flush();
     assert.isFalse(element.classList.contains('private'));
     assert.isTrue(element.classList.contains('wip'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index f97146e..4e953e8 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
@@ -53,12 +53,14 @@
 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';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -335,7 +337,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[]) {
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..1f0ce3d 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);
     }
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_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..a5a5433 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
@@ -110,8 +110,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 +143,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 +426,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 +442,6 @@
       return;
     }
 
-    const dims = this._getWindowDims();
     const top = this._getTop(this.target);
     const bottomIsVisible = this._targetHeight
       ? this._targetIsVisible(top + this._targetHeight)
@@ -435,7 +453,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 +462,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..01b9a74 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
@@ -282,12 +282,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 +296,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 +307,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-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-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 2259ca1..e3a34c8 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,14 @@
       <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>
     </defs>
   </svg>
 </iron-iconset-svg>`;
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-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-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 85b6747..fe101c5 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;
@@ -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..35acdef 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';
@@ -187,11 +187,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-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index ef0367f..6fb4cee 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
@@ -2316,12 +2316,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-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index bf10121..6125d33 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -271,15 +271,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.
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6999ef8..ffee627 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -21,14 +21,22 @@
 // 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 {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
+import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
 initDiffAppContext();
 // Setup global variables for existing usages of this component
+window.grdiff = {
+  GrAnnotation,
+  TokenHighlightLayer,
+};
+
+// TODO(oler): Remove when clients have adjusted to namespaced globals above
 window.GrAnnotation = GrAnnotation;
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..837a795 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
@@ -103,10 +103,12 @@
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
 import {Constructor} from '../../utils/common-util';
+import {getKeyboardEvent, isModifierPressed} from '../../utils/dom-util';
 import {
   CustomKeyboardEvent,
   ShortcutTriggeredEventDetail,
 } from '../../types/events';
+import {appContext} from '../../services/app-context';
 
 /** Enum for all special shortcuts */
 export enum SPECIAL_SHORTCUT {
@@ -530,19 +532,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.
  */
@@ -761,16 +750,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 +784,10 @@
 
       ShortcutSection = ShortcutSection;
 
+      private _disableKeyboardShortcuts = false;
+
+      private readonly restApiService = appContext.restApiService;
+
       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 +795,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;
@@ -927,6 +902,13 @@
       /** @override */
       connectedCallback() {
         super.connectedCallback();
+
+        this.restApiService.getPreferences().then(prefs => {
+          if (prefs?.disable_keyboard_shortcuts) {
+            this._disableKeyboardShortcuts = true;
+          }
+        });
+
         const shortcuts = shortcutManager.attachHost(this);
         if (!shortcuts) {
           return;
@@ -1087,8 +1069,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..b22b8d8 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
@@ -377,27 +377,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 c943bff..d250537 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,6 +35,7 @@
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
+    "codemirror-minified": "^5.60.0",
     "lit-element": "^2.4.0",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 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-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index c292fb5..1c6fc4c 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -17,8 +17,16 @@
 import {routerChangeNum$} from '../router/router-model';
 import {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 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
@@ -38,4 +46,35 @@
   updateChange(change: ParsedChangeInfo) {
     updateState(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-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 250cea5..c517fb89 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -54,6 +54,7 @@
 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';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
@@ -64,7 +65,7 @@
 
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
-  constructor() {
+  constructor(readonly reporting: ReportingService) {
     checkToPluginMap$.subscribe(map => {
       this.checkToPluginMap = map;
     });
@@ -150,8 +151,7 @@
               commmitMessage: getCurrentRevision(change)?.commit?.message,
               changeInfo: change,
             };
-            updateStateSetLoading(pluginName);
-            return from(this.providers[pluginName].fetch(data));
+            return this.fetchResults(pluginName, data);
           }
         ),
         catchError(e => {
@@ -183,4 +183,16 @@
         }
       });
   }
+
+  private fetchResults(pluginName: string, data: ChangeData) {
+    updateStateSetLoading(pluginName);
+    const timer = this.reporting.getTimer('ChecksPluginFetch');
+    const fetchPromise = this.providers[pluginName]
+      .fetch(data)
+      .then(response => {
+        timer.end({pluginName});
+        return response;
+      });
+    return from(fetchPromise);
+  }
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index d7081ce..bbdb02a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -24,7 +24,7 @@
 
 export interface Timer {
   reset(): this;
-  end(): this;
+  end(eventDetails?: EventDetails): this;
   withMaximum(maximum: number): this;
 }
 
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..4e6ece2 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -713,7 +713,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 +725,7 @@
           return timer;
         }
 
-        this._reportTiming(name, time);
+        this._reportTiming(name, time, eventDetails);
         return timer;
       },
 
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 30bf490..2d5242e 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>;
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/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..223300b 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;
+  accountsInMessage?: AccountInfo[];
   tag?: ReviewInputTag;
   _revision_number?: PatchSetNum;
 }
@@ -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..d30917a0 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -74,6 +74,11 @@
 export interface DiffFileMetaInfo extends DiffFileMetaInfoApi {
   /** Links to the file in external sites as a list of WebLinkInfo entries. */
   web_links?: WebLinkInfo[];
+  /**
+   * 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..2debbfe 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -56,6 +56,7 @@
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
+    'create-fix-comment': CreateFixCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
     /* prettier-ignore */
@@ -135,6 +136,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;
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..dd91f7b 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -134,6 +134,7 @@
   return change?.status === ChangeStatus.NEW;
 }
 
+// TODO(TS): use enum ChangeStates in gr-change-status
 export function changeStatuses(
   change: ChangeInfo,
   opt_options?: ChangeStatusesOptions
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/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
index eefdcda..93073fa 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ b/polygerrit-ui/app/utils/patch-set-util_test.js
@@ -18,7 +18,7 @@
 import '../test/common-test-setup-karma.js';
 import {
   _testOnly_computeWipForPatchSets, computeAllPatchSets,
-  fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
+  findEditParentPatchNum, findEditParentRevision,
   getParentIndex, getRevisionByPatchNum,
   isMergeParent,
   sortRevisions,
@@ -36,123 +36,6 @@
     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
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 77d3972..fa35f288 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,6 +418,11 @@
 "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"
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..9b9d752 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -46,7 +46,6 @@
 	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.]*")
 )
 
 func main() {
@@ -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/proto/cache.proto b/proto/cache.proto
index 292a225..b1722b4 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -468,6 +468,17 @@
   bool copy_all_scores_if_list_of_files_did_not_change = 19;
 }
 
+// Serialized form of com.google.gerrit.entities.SubmitRequirement.
+// Next ID: 7
+message SubmitRequirementProto {
+  string name = 1;
+  string description = 2;
+  string applicability_expression = 3;
+  string blocking_expression = 4;
+  string override_expression = 5;
+  bool allow_override_in_child_projects = 6;
+}
+
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
 // Next ID: 4
 message ConfiguredMimeTypeProto {
@@ -496,7 +507,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 +527,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 {
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/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/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 f86fd3e..7caeb5d 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 1df4b82..0000000
--- a/tools/js/download_bower.py
+++ /dev/null
@@ -1,135 +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.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 8e47603..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.0-rc4</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 78767dd..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.0-rc4</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 3ceda6f..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.0-rc4</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 c3a64c8..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.0-rc4</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..6e29299 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.4.0",
+    "@bazel/typescript": "^3.4.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..b8ac5fb 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.4.0":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.4.0.tgz#cdecb2b90535ef51fb3d56cc8bc19996918bac1a"
+  integrity sha512-QKnttbYyEQjRbWrOlkH2JuDnSww+9K7Ppil91zBTtr/qYTGW9XO0v7Ft3cs30s2NIWSGIuKj9/N5as+Uyratrw==
 
-"@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.4.0":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.4.0.tgz#031d989682ff8605ed8745f31448c2f76a1b653a"
+  integrity sha512-XlWrlQnsdQHTwsliUAf4mySHOgqRY2S57LKG2rKRjm+a015Lzlmxo6jRQaxjr68UmuhmlklRw0WfCFxdR81AvQ==
   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/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 75804f8..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.0-rc4"
+GERRIT_VERSION = "3.5.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 28a4a08..ead8dbf 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.4.0":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.4.0.tgz#cdecb2b90535ef51fb3d56cc8bc19996918bac1a"
+  integrity sha512-QKnttbYyEQjRbWrOlkH2JuDnSww+9K7Ppil91zBTtr/qYTGW9XO0v7Ft3cs30s2NIWSGIuKj9/N5as+Uyratrw==
 
-"@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.4.0":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.4.0.tgz#9a25892977f00974e4195ff6cbe71ec0313a77d5"
+  integrity sha512-E26ijh44aXIXcg3EQEZcL2nkGlWZtMka0gwmYo9bDRyGt6rCRhFuSBC0mz9YCifUhKuACKWXLHPz9wvh1CDkEA==
 
-"@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.4.0":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.4.0.tgz#031d989682ff8605ed8745f31448c2f76a1b653a"
+  integrity sha512-XlWrlQnsdQHTwsliUAf4mySHOgqRY2S57LKG2rKRjm+a015Lzlmxo6jRQaxjr68UmuhmlklRw0WfCFxdR81AvQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"