Merge branch 'stable-3.4'

* stable-3.4:
  Submit: Remove unused changeDataFactory attribute
  Evaluate SubmitRules at most once when formatting ChangeInfo
  Add test to show how often SubmitRules are run
  Adapt SendMessage of the NoShell command to AsyncCommand type
  gr-registration-dialog: Hide username field if it is not editable
  Update jgit to 73f8acdc5c97e068143c86765995c4fb6923ee91
  Clarify the CI validation process for security fixes
  Add a section on reducing the impacts of Git gc
  Fix registration redirect on OpenID
  Fix gr-registration-dialog
  FilePathAdapter: Add license header
  Add a section about the impacts of Git gc
  Add a repository-maintenance doc
  Documentation/user-review-ui: drop change owner description
  polygerrit-ui: add a tooltip for 'Updated' metadata field
  Documentation/user-review: new screenshot for file level comments
  Documentation/user-review: trim outdated info from diff prefs
  Documentation/user-review: remove syntax coloring screenshot
  Documentation/user-review: update diff preferences section
  Fix the context control button rendering in Safari
  Update jgit to f2e5bace4841758927d47db7d20e4a6f7353ce57
  Update jgit to 00386272264f65c41e36406f7c2e9ea6e901276e
  Documentation/user-review: remove VIM search reference
  Documentation/user-review: remove scrollbar screenshot
  Documentation/user-review: update toggle review screenshot
  Documentation/user-review: remove mention of red bar
  Documentation/user-review: update quick approve screenshot
  Documentation/user-review: remove screenshot for last update
  Documentation/user-review-ui: remove mention of VIM navigation
  Log when a new SSH connection is rejected due to exceeded limit

Change-Id: Ic9aaa6f140691afc50fafebce65cbcc46ee9873d
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 5f2ea09..495a573 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1969,7 +1969,7 @@
   scheme = http
   scheme = anon_http
   scheme = anon_git
-  scheme = repo_download
+  scheme = repo
 ----
 
 The download section configures the allowed download methods.
@@ -2026,12 +2026,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 cfe0ffb..61565f8 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -19,7 +19,7 @@
 
 * A Linux or macOS system (Windows is not supported at this time)
 * A JDK for Java 8|11|...
-* Python 2 or 3
+* Python 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Bower (`npm install -g bower`)
 * link:https://docs.bazel.build/versions/master/install.html[Bazel,role=external,window=_blank] -launched with
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 01857da..fcc8b7e 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -174,7 +174,8 @@
 While the design doc is still in review, contributors may already start
 with the implementation (e.g. do some prototyping to demonstrate parts
 of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet.
+while the design wasn't approved yet. Another way to demonstrate the
+design can be to add screenshots or the like, early enough in the doc.
 
 By approving a design, the steering committee commits to:
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index a7240e2..0849c56 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -153,7 +153,7 @@
 Tag the plugins:
 
 ----
-  git submodule foreach '[ "$path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
+  git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
 ----
 
 [[build-gerrit]]
@@ -324,7 +324,7 @@
 Push the new Release Tag on the plugins:
 
 ----
-  git submodule foreach git push gerrit-review tag v$version
+  git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
 [[upload-documentation]]
diff --git a/Documentation/intro-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..ff12af2 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1741,6 +1741,18 @@
 As response a link:#change-info[ChangeInfo] entity is returned that
 describes the submitted/merged change.
 
+Submission may submit multiple changes, but we still only return one ChangeInfo
+object. To query for all submitted changes, please use the submission_id that is
+part of the response.
+
+Changes that will also be submitted:
+1. All changes of the same topic if
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+configuration is set to true.
+2. All dependent changes.
+3. The closure of the above (e.g if a dependent change has a topic, all changes
+of *that* topic will also be submitted).
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -3360,7 +3372,7 @@
   }
 ----
 
-As response an link:#add-reviewer-result[AddReviewerResult] entity is
+As response an link:#reviewer-result[ReviewerResult] entity is
 returned that describes the newly added reviewers.
 
 .Response
@@ -3573,6 +3585,9 @@
 Deletes a single vote from a change. Note, that even when the last vote of
 a reviewer is removed the reviewer itself is still listed on the change.
 
+If another user removed a user's vote, the user with the deleted vote will be
+added to the attention set.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -4163,7 +4178,7 @@
 Each element of the `reviewers` list is an instance of
 link:#reviewer-input[ReviewerInput]. The corresponding result of
 adding each reviewer will be returned in a map of inputs to
-link:#add-reviewer-result[AddReviewerResult]s.
+link:#reviewer-result[ReviewerResult]s.
 
 .Response
 ----
@@ -4403,14 +4418,27 @@
 --
 
 Submits a revision.
+Submitting a change also removes all users from the link:#attention-set[attention set].
 
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/submit HTTP/1.0
 ----
 
-As response a link:#submit-info[SubmitInfo] entity is returned that
-describes the status of the submitted change.
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the submitted/merged change.
+
+Submission may submit multiple changes, but we still only return one ChangeInfo
+object. To query for all submitted changes, please use the submission_id that is
+part of the response.
+
+Changes that will also be submitted:
+1. All changes of the same topic if
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+configuration is set to true.
+2. All dependent changes.
+3. The closure of the above (e.g if a dependent change has a topic, all changes
+of *that* topic will also be submitted).
 
 .Response
 ----
@@ -4420,7 +4448,19 @@
 
   )]}'
   {
-    "status": "MERGED"
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "project": "myProject",
+    "branch": "master",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "subject": "Implementing Feature X",
+    "status": "MERGED",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "submitted": "2013-02-21 11:16:36.615000000",
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
   }
 ----
 
@@ -5800,6 +5840,11 @@
 link:#cherrypick-input[CherryPickInput] entity.  If the commit message
 does not specify a Change-Id, a new one is picked for the destination change.
 
+When cherry-picking a change into a branch that already contains the Change-Id
+that we want to cherry-pick, the cherry-pick will create a new patch-set on the
+destination's branch's appropriate Change-Id. If the change is closed on the
+destination branch, the cherry-pick will fail.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/cherrypick HTTP/1.0
@@ -5937,6 +5982,9 @@
 Note, that even when the last vote of a reviewer is removed the reviewer itself
 is still listed on the change.
 
+If another user removed a user's vote, the user with the deleted vote will be
+added to the attention set.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -6115,6 +6163,9 @@
 * The change is marked ready for review.
 * As an owner/uploader, when someone replies on your change.
 * As a reviewer, when the owner/uploader replies.
+* When the user's vote is deleted by another user.
+* The rules above (except manually adding to the attention set) don't apply
+ for changes that are work in progress.
 
 Users are removed from the attention set if one the following apply:
 
@@ -6258,33 +6309,6 @@
 at the server or permissions are modified. Not present if false.
 |====================================
 
-[[add-reviewer-result]]
-=== AddReviewerResult
-The `AddReviewerResult` entity describes the result of adding a
-reviewer to a change.
-
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`input`    ||
-Value of the `reviewer` field from link:#reviewer-input[ReviewerInput]
-set while adding the reviewer.
-|`reviewers`   |optional|
-The newly added reviewers as a list of link:#reviewer-info[
-ReviewerInfo] entities.
-|`ccs`         |optional|
-The newly CCed accounts as a list of link:#reviewer-info[
-ReviewerInfo] entities. This field will only appear if the requested
-`state` for the reviewer was `CC` *and* NoteDb is enabled on the
-server.
-|`error`       |optional|
-Error message explaining why the reviewer could not be added. +
-If a group was specified in the input and an error is returned, it
-means that none of the members were added as reviewer.
-|`confirm`     |`false` if not set|
-Whether adding the reviewer requires confirmation.
-|===========================
-
 [[approval-info]]
 === ApprovalInfo
 The `ApprovalInfo` entity contains information about an approval from a
@@ -6429,8 +6453,7 @@
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
 |`hashtags`           |optional|
-List of hashtags that are set on the change (only populated when NoteDb
-is enabled).
+List of hashtags that are set on the change.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6664,7 +6687,12 @@
 Set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
-|`message`            ||The text left by the user.
+|`message`            ||
+The text left by the user or Gerrit system. Accounts are served as account IDs
+inlined in the text as `<GERRIT_ACCOUNT_18419>`.
+All accounts, used in message, can be found in `accountsInMessage`
+field.
+|`accountsInMessage`            ||Accounts, used in `message`.
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review. Votes/comments that contain `tag` with
@@ -6893,7 +6921,10 @@
 The subject of the commit (header line of the commit message).
 |`message`     ||The commit message.
 |`web_links`   |optional|
-Links to the commit in external sites as a list of
+Links to the patch set in external sites as a list of
+link:#web-link-info[WebLinkInfo] entities.
+|`resolve_conflicts_web_links`   |optional|
+Links to the commit in external sites for resolving conflicts as a list of
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
@@ -7069,6 +7100,9 @@
 |`web_links`       |optional|
 Links to the file diff in external sites as a list of
 link:rest-api-changes.html#diff-web-link-info[DiffWebLinkInfo] entries.
+|`edit_web_links`   |optional|
+Links to edit the file in external sites as a list of
+link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
 |`binary`          |not set if `false`|Whether the file is binary.
 |==========================
 
@@ -7773,7 +7807,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 +7840,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 +7872,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 +7907,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 +8069,6 @@
 to return results from the input rule.
 |===========================
 
-[[submit-info]]
-=== SubmitInfo
-The `SubmitInfo` entity contains information about the change status
-after submitting.
-
-[options="header",cols="1,^1,5"]
-|==========================
-|Field Name    ||Description
-|`status`      ||
-The status of the change after submitting is `MERGED`.
-|`on_behalf_of`|optional|
-The link:rest-api-accounts.html#account-id[\{account-id\}] of the user on
-whose behalf the action should be done. To use this option the caller must
-have been granted both `Submit` and `Submit (On Behalf Of)` permissions.
-The user named by `on_behalf_of` does not need to be granted the `Submit`
-permission. This feature is aimed for CI solutions: the CI account can be
-granted both permissions, so individual users don't need `Submit` permission
-themselves. Still the changes can be submitted on behalf of real users and
-not with the identity of the CI account.
-|==========================
-
 [[submit-input]]
 === SubmitInput
 The `SubmitInput` entity contains information for submitting a change.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index e30ce3a..df83f1a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2621,6 +2621,11 @@
 If the commit message is not set, the commit message of the source
 commit will be used.
 
+When cherry-picking a commit into a branch that already contains the Change-Id
+that we want to cherry-pick, the cherry-pick will create a new patch-set on the
+destination's branch's appropriate Change-Id. If the change is closed on the
+destination branch, the cherry-pick will fail.
+
 .Request
 ----
   POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
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 156ea02..1ea8bd8 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -324,6 +324,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/gwt-user-review-ui-change-screen-file-list-modification-type.png[width=800, link="images/gwt-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..3e8cf3b 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1268,6 +1268,7 @@
     assertThat(diff.diffHeader).isNotNull();
     assertThat(diff.intralineStatus).isNull();
     assertThat(diff.webLinks).isNull();
+    assertThat(diff.editWebLinks).isNull();
 
     assertThat(diff.metaA).isNull();
     assertThat(diff.metaB).isNotNull();
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 1b0954e..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..85c4c13 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -32,8 +32,10 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
@@ -75,6 +77,8 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
+  private final DynamicSet<EditWebLink> editWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -109,6 +113,8 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
+      DynamicSet<EditWebLink> editWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -139,6 +145,8 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
+    this.editWebLinks = editWebLinks;
+    this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -240,6 +248,14 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    public Registration add(ResolveConflictsWebLink resolveConflictsWebLink) {
+      return add(resolveConflictsWebLinks, resolveConflictsWebLink);
+    }
+
+    public Registration add(EditWebLink editWebLink) {
+      return add(editWebLinks, editWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
diff --git a/java/com/google/gerrit/acceptance/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/PluginData.java b/java/com/google/gerrit/common/PluginData.java
index c440de1..289d93a 100644
--- a/java/com/google/gerrit/common/PluginData.java
+++ b/java/com/google/gerrit/common/PluginData.java
@@ -10,7 +10,7 @@
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
-// limitations under the License.package com.google.gerrit.common;
+// limitations under the License.
 
 package com.google.gerrit.common;
 
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 51d9ecd..8bfd960 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -109,6 +109,9 @@
   /** Can perform streaming of Gerrit events. */
   public static final String STREAM_EVENTS = "streamEvents";
 
+  /** Can query permissions for any (project, user) pair */
+  public static final String VIEW_ACCESS = "viewAccess";
+
   /** Can view all accounts, regardless of {@code accounts.visibility}. */
   public static final String VIEW_ALL_ACCOUNTS = "viewAllAccounts";
 
@@ -124,9 +127,6 @@
   /** Can view all pending tasks in the queue (not just the filtered set). */
   public static final String VIEW_QUEUE = "viewQueue";
 
-  /** Can query permissions for any (project, user) pair */
-  public static final String VIEW_ACCESS = "viewAccess";
-
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
   private static final String[] RANGE_NAMES = {
@@ -152,12 +152,12 @@
     NAMES_ALL.add(RUN_AS);
     NAMES_ALL.add(RUN_GC);
     NAMES_ALL.add(STREAM_EVENTS);
+    NAMES_ALL.add(VIEW_ACCESS);
     NAMES_ALL.add(VIEW_ALL_ACCOUNTS);
     NAMES_ALL.add(VIEW_CACHES);
     NAMES_ALL.add(VIEW_CONNECTIONS);
     NAMES_ALL.add(VIEW_PLUGINS);
     NAMES_ALL.add(VIEW_QUEUE);
-    NAMES_ALL.add(VIEW_ACCESS);
 
     NAMES_LC = new ArrayList<>(NAMES_ALL.size());
     for (String name : NAMES_ALL) {
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 2a94bc8..8740235 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -95,6 +95,9 @@
   /** Returns the {@link LabelType}s keyed by their name. */
   public abstract ImmutableMap<String, LabelType> getLabelSections();
 
+  /** Returns the {@link SubmitRequirement}s keyed by their name. */
+  public abstract ImmutableMap<String, SubmitRequirement> getSubmitRequirementSections();
+
   /** Returns configured {@link ConfiguredMimeTypes}s. */
   public abstract ConfiguredMimeTypes getMimeTypes();
 
@@ -169,6 +172,11 @@
       return this;
     }
 
+    public Builder addSubmitRequirementSection(SubmitRequirement submitRequirement) {
+      submitRequirementSectionsBuilder().put(submitRequirement.name(), submitRequirement);
+      return this;
+    }
+
     public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
 
     public Builder addSubscribeSection(SubscribeSection subscribeSection) {
@@ -236,6 +244,9 @@
 
     protected abstract ImmutableMap.Builder<String, LabelType> labelSectionsBuilder();
 
+    protected abstract ImmutableMap.Builder<String, SubmitRequirement>
+        submitRequirementSectionsBuilder();
+
     protected abstract ImmutableMap.Builder<Project.NameKey, SubscribeSection>
         subscribeSectionsBuilder();
 
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index f34cc7d..e10d002 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -15,12 +15,35 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.util.HashSet;
 import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
-/** A message attached to a {@link Change}. */
+/**
+ * A message attached to a {@link Change}. This message is persisted in data storage, that is why it
+ * must have template form that does not contain Gerrit user identifiable information. Hence, it
+ * requires processing to convert it to user-facing form.
+ *
+ * <p>These messages are normally auto-generated by gerrit operations, but might also incorporate
+ * user input.
+ */
 public final class ChangeMessage {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Template to identify an account in {@link ChangeMessage#message}. */
+  public static final String ACCOUNT_TEMPLATE = "<GERRIT_ACCOUNT_%d>";
+
+  public static final Pattern ACCOUNT_TEMPLATE_PATTERN =
+      Pattern.compile("<GERRIT_ACCOUNT_([0-9]+)>");
+
   public static Key key(Change.Id changeId, String uuid) {
     return new AutoValue_ChangeMessage_Key(changeId, uuid);
   }
@@ -40,9 +63,15 @@
   /** When this comment was drafted. */
   protected Timestamp writtenOn;
 
-  /** The text left by the user. */
+  /**
+   * The text left by the user or Gerrit system in template form, that is free of Gerrit User
+   * Identifiable Information and can be persisted in data storage.
+   */
   @Nullable protected String message;
 
+  /** {@link Account.Id}s that are used in {@link #message} template. */
+  protected ImmutableSet<Account.Id> accountsInMessage;
+
   /** Which patchset (if any) was this message generated from? */
   @Nullable protected PatchSet.Id patchset;
 
@@ -54,11 +83,47 @@
 
   protected ChangeMessage() {}
 
-  public ChangeMessage(final ChangeMessage.Key k, Account.Id a, Timestamp wo, PatchSet.Id psid) {
-    key = k;
-    author = a;
-    writtenOn = wo;
-    patchset = psid;
+  public static ChangeMessage create(
+      final ChangeMessage.Key k, @Nullable Account.Id a, Timestamp wo, @Nullable PatchSet.Id psid) {
+    return create(k, a, wo, psid, /*messageTemplate=*/ null, /*realAuthor=*/ null, /*tag=*/ null);
+  }
+
+  public static ChangeMessage create(
+      final ChangeMessage.Key k,
+      @Nullable Account.Id a,
+      Timestamp wo,
+      @Nullable PatchSet.Id psid,
+      @Nullable String messageTemplate,
+      @Nullable Account.Id realAuthor,
+      @Nullable String tag) {
+    ChangeMessage message = new ChangeMessage();
+    message.key = k;
+    message.author = a;
+    message.writtenOn = wo;
+    message.patchset = psid;
+    message.message = messageTemplate;
+    message.accountsInMessage =
+        messageTemplate == null ? ImmutableSet.of() : parseTemplates(messageTemplate);
+    // Use null for same real author, as before the column was added.
+    message.realAuthor = Objects.equals(a, realAuthor) ? null : realAuthor;
+    message.tag = tag;
+    return message;
+  }
+
+  /* Returns account ids that are used in {@code messageTemplate}. */
+  public static ImmutableSet<Account.Id> parseTemplates(String messageTemplate) {
+    Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
+    Set<Account.Id> accountsInTemplate = new HashSet<>();
+    while (matcher.find()) {
+      String accountId = matcher.group(1);
+      Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
+      if (parsedAccountId.isPresent()) {
+        accountsInTemplate.add(parsedAccountId.get());
+      } else {
+        logger.atFine().log("Failed to parse accountId from template %s", matcher.group());
+      }
+    }
+    return ImmutableSet.copyOf(accountsInTemplate);
   }
 
   public ChangeMessage.Key getKey() {
@@ -70,54 +135,32 @@
     return author;
   }
 
-  public void setAuthor(Account.Id accountId) {
-    if (author != null) {
-      throw new IllegalStateException("Cannot modify author once assigned");
-    }
-    author = accountId;
-  }
-
   public Account.Id getRealAuthor() {
     return realAuthor != null ? realAuthor : getAuthor();
   }
 
-  public void setRealAuthor(Account.Id id) {
-    // Use null for same real author, as before the column was added.
-    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
-  }
-
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
 
-  public void setWrittenOn(Timestamp ts) {
-    writtenOn = ts;
-  }
-
+  /** Message template, as persisted in data storage. */
   public String getMessage() {
     return message;
   }
 
-  public void setMessage(String s) {
-    message = s;
+  /** Account ids, used in {@link #message} template. */
+  public ImmutableSet<Account.Id> getAccountsInMessage() {
+    return accountsInMessage == null ? ImmutableSet.of() : accountsInMessage;
   }
 
   public String getTag() {
     return tag;
   }
 
-  public void setTag(String tag) {
-    this.tag = tag;
-  }
-
   public PatchSet.Id getPatchSetId() {
     return patchset;
   }
 
-  public void setPatchSetId(PatchSet.Id id) {
-    patchset = id;
-  }
-
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof ChangeMessage)) {
@@ -128,6 +171,7 @@
         && Objects.equals(author, m.author)
         && Objects.equals(writtenOn, m.writtenOn)
         && Objects.equals(message, m.message)
+        && Objects.equals(accountsInMessage, m.accountsInMessage)
         && Objects.equals(patchset, m.patchset)
         && Objects.equals(tag, m.tag)
         && Objects.equals(realAuthor, m.realAuthor);
@@ -135,7 +179,8 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(key, author, writtenOn, message, patchset, tag, realAuthor);
+    return Objects.hash(
+        key, author, writtenOn, message, accountsInMessage, patchset, tag, realAuthor);
   }
 
   @Override
@@ -155,6 +200,8 @@
         + tag
         + ", message=["
         + message
-        + "]}";
+        + "], accountsInMessage="
+        + accountsInMessage
+        + "}";
   }
 }
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 9bcd365..85e55a0 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -20,7 +20,6 @@
   public static final String ANON_HTTP = "anonymous http";
   public static final String HTTP = "http";
   public static final String SSH = "ssh";
-  public static final String REPO_DOWNLOAD = "repo";
   public static final String REPO = "repo";
 
   private CoreDownloadSchemes() {}
diff --git a/java/com/google/gerrit/entities/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/entities/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index 19c121249..eb2a381 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -48,6 +48,8 @@
     if (writtenOn != null) {
       builder.setWrittenOn(writtenOn.getTime());
     }
+    // Build proto with template representation of the message. Templates are parsed when message is
+    // extracted from cache.
     String message = changeMessage.getMessage();
     if (message != null) {
       builder.setMessage(message);
@@ -79,16 +81,15 @@
     Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
     PatchSet.Id patchSetId =
         proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
-    ChangeMessage changeMessage = new ChangeMessage(key, author, writtenOn, patchSetId);
-    if (proto.hasMessage()) {
-      changeMessage.setMessage(proto.getMessage());
-    }
-    if (proto.hasTag()) {
-      changeMessage.setTag(proto.getTag());
-    }
-    if (proto.hasRealAuthor()) {
-      changeMessage.setRealAuthor(accountIdConverter.fromProto(proto.getRealAuthor()));
-    }
+    // Only template representation of the message is stored in entity. Templates should be replaced
+    // before being served to the users.
+    String messageTemplate = proto.hasMessage() ? proto.getMessage() : null;
+    String tag = proto.hasTag() ? proto.getTag() : null;
+    Account.Id realAuthor =
+        proto.hasRealAuthor() ? accountIdConverter.fromProto(proto.getRealAuthor()) : null;
+    ChangeMessage changeMessage =
+        ChangeMessage.create(key, author, writtenOn, patchSetId, messageTemplate, realAuthor, tag);
+
     return changeMessage;
   }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 7cbfebd..04f5bd2 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -205,13 +205,13 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  default AddReviewerResult addReviewer(String reviewer) throws RestApiException {
-    AddReviewerInput in = new AddReviewerInput();
+  default ReviewerResult addReviewer(String reviewer) throws RestApiException {
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(in);
   }
 
-  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
+  ReviewerResult addReviewer(ReviewerInput in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
@@ -643,7 +643,7 @@
     }
 
     @Override
-    public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+    public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index fd445b6..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/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 10456ff..c1cb1627 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Collection;
 import java.util.Objects;
 
+/** Represent {@link com.google.gerrit.entities.ChangeMessage} in the REST API. */
 public class ChangeMessageInfo {
   public String id;
   public String tag;
@@ -24,6 +26,7 @@
   public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
+  public Collection<AccountInfo> accountsInMessage;
   public Integer _revisionNumber;
 
   public ChangeMessageInfo() {}
@@ -42,6 +45,7 @@
           && Objects.equals(realAuthor, cmi.realAuthor)
           && Objects.equals(date, cmi.date)
           && Objects.equals(message, cmi.message)
+          && Objects.equals(accountsInMessage, cmi.accountsInMessage)
           && Objects.equals(_revisionNumber, cmi._revisionNumber);
     }
     return false;
@@ -49,7 +53,8 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(id, tag, author, realAuthor, date, message, _revisionNumber);
+    return Objects.hash(
+        id, tag, author, realAuthor, date, message, accountsInMessage, _revisionNumber);
   }
 
   @Override
@@ -69,6 +74,8 @@
         + _revisionNumber
         + ", message=["
         + message
-        + "]}";
+        + "], accountsForTemplate="
+        + accountsInMessage
+        + "}";
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
index 1fd8755..202b829 100644
--- a/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -29,6 +29,7 @@
   public String subject;
   public String message;
   public List<WebLinkInfo> webLinks;
+  public List<WebLinkInfo> resolveConflictsWebLinks;
 
   @Override
   public boolean equals(Object o) {
@@ -42,12 +43,14 @@
         && Objects.equals(committer, c.committer)
         && Objects.equals(subject, c.subject)
         && Objects.equals(message, c.message)
-        && Objects.equals(webLinks, c.webLinks);
+        && Objects.equals(webLinks, c.webLinks)
+        && Objects.equals(resolveConflictsWebLinks, c.resolveConflictsWebLinks);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(commit, parents, author, committer, subject, message, webLinks);
+    return Objects.hash(
+        commit, parents, author, committer, subject, message, webLinks, resolveConflictsWebLinks);
   }
 
   @Override
@@ -64,6 +67,9 @@
     if (webLinks != null) {
       helper.add("webLinks", webLinks);
     }
+    if (resolveConflictsWebLinks != null) {
+      helper.add("resolveConflictsWebLinks", resolveConflictsWebLinks);
+    }
     return helper.toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/DiffInfo.java b/java/com/google/gerrit/extensions/common/DiffInfo.java
index 2511e96..5a9b82b 100644
--- a/java/com/google/gerrit/extensions/common/DiffInfo.java
+++ b/java/com/google/gerrit/extensions/common/DiffInfo.java
@@ -32,6 +32,8 @@
   public List<ContentEntry> content;
   // Links to the file diff in external sites
   public List<DiffWebLinkInfo> webLinks;
+  // Links to edit the file in external sites
+  public List<WebLinkInfo> editWebLinks;
   // Binary file
   public Boolean binary;
 
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 510c2ad..c732663 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -44,4 +44,24 @@
   public int hashCode() {
     return Objects.hash(status, binary, oldPath, linesInserted, linesDeleted, sizeDelta, size);
   }
+
+  @Override
+  public String toString() {
+    return "FileInfo{"
+        + "status="
+        + status
+        + ", binary="
+        + binary
+        + ", oldPath="
+        + oldPath
+        + ", linesInserted="
+        + linesInserted
+        + ", linesDeleted="
+        + linesDeleted
+        + ", sizeDelta="
+        + sizeDelta
+        + ", size="
+        + size
+        + "}";
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d344e18..71fc564 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -20,6 +20,7 @@
 
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -68,6 +69,16 @@
     return check("message").that(commitInfo.message);
   }
 
+  public IterableSubject webLinks() {
+    isNotNull();
+    return check("webLinks").that(commitInfo.webLinks);
+  }
+
+  public IterableSubject resolveConflictsWebLinks() {
+    isNotNull();
+    return check("resolveConflictsWebLinks").that(commitInfo.resolveConflictsWebLinks);
+  }
+
   public static Correspondence<CommitInfo, String> hasCommit() {
     return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
   }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index e258134..b800d17 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -74,6 +74,11 @@
     return check("webLinks").that(diffInfo.webLinks);
   }
 
+  public IterableSubject editWebLinks() {
+    isNotNull();
+    return check("editWebLinks").that(diffInfo.editWebLinks);
+  }
+
   public BooleanSubject binary() {
     isNotNull();
     return check("binary").that(diffInfo.binary);
diff --git a/java/com/google/gerrit/extensions/webui/EditWebLink.java b/java/com/google/gerrit/extensions/webui/EditWebLink.java
new file mode 100644
index 0000000..cd70feb
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/EditWebLink.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface EditWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a file to an
+   * external service for editing.
+   *
+   * <p>In order for the web link to be visible {@link WebLinkInfo#url} and {@link WebLinkInfo#name}
+   * must be set.
+   *
+   * @param projectName name of the project
+   * @param revision name of the revision (e.g. branch or commit ID)
+   * @param fileName name of the file
+   * @return WebLinkInfo that links to project in external service, null if there should be no link.
+   */
+  WebLinkInfo getEditWebLink(String projectName, String revision, String fileName);
+}
diff --git a/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
new file mode 100644
index 0000000..19402a9
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface ResolveConflictsWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service for the purpose of resolving merge conflicts.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @return WebLinkInfo that links to patch set in external service, {@code null} if there should
+   *     be no link.
+   */
+  WebLinkInfo getResolveConflictsWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
+}
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index b9d15d2..2f21bf3 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -30,7 +30,7 @@
    *     the same as {@code setVisible(false)}.
    */
   @Nullable
-  Description getDescription(R resource);
+  Description getDescription(R resource) throws Exception;
 
   /** Describes an action invokable through the web interface. */
   class Description {
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index d03340b..3a77a8a 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -346,7 +346,7 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(false, false, ""));
+            bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
             bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
           }
         });
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 8d52f5a..445a73a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
@@ -90,13 +91,13 @@
     switch (page) {
       case CHANGE:
         data.put(
-            "defaultChangeDetailHex", IndexPreloadingUtil.getDefaultChangeDetailOptionsAsHex());
+            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         break;
       case DIFF:
-        data.put("defaultDiffDetailHex", IndexPreloadingUtil.getDefaultDiffDetailOptionsAsHex());
+        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
@@ -120,7 +121,7 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
       if (page == RequestedPage.DASHBOARD) {
-        data.put("defaultDashboardHex", IndexPreloadingUtil.getDefaultDashboardHex(serverApi));
+        data.put("defaultDashboardHex", ListOption.toHex(IndexPreloadingUtil.DASHBOARD_OPTIONS));
         data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
       }
     } catch (AuthException e) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index f1da6b7..98e660c 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -24,16 +24,13 @@
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.Url;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
-import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
@@ -91,43 +88,26 @@
               NEW_USER)
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
+  public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
+      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
 
-  public static String getDefaultChangeDetailOptionsAsHex() {
-    Set<ListChangesOption> options =
-        ImmutableSet.of(
-            ListChangesOption.ALL_COMMITS,
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.CHANGE_ACTIONS,
-            ListChangesOption.DETAILED_LABELS,
-            ListChangesOption.DOWNLOAD_COMMANDS,
-            ListChangesOption.MESSAGES,
-            ListChangesOption.SUBMITTABLE,
-            ListChangesOption.WEB_LINKS,
-            ListChangesOption.SKIP_DIFFSTAT);
+  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DETAILED_LABELS,
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.MESSAGES,
+          ListChangesOption.SUBMITTABLE,
+          ListChangesOption.WEB_LINKS,
+          ListChangesOption.SKIP_DIFFSTAT);
 
-    return ListOption.toHex(options);
-  }
-
-  public static String getDefaultDiffDetailOptionsAsHex() {
-    Set<ListChangesOption> options =
-        ImmutableSet.of(
-            ListChangesOption.ALL_COMMITS,
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.SKIP_DIFFSTAT);
-
-    return ListOption.toHex(options);
-  }
-
-  public static String getDefaultDashboardHex(Server serverApi) throws RestApiException {
-    Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
-    options.add(ListChangesOption.LABELS);
-    options.add(ListChangesOption.DETAILED_ACCOUNTS);
-
-    if (!isEnabledAttentionSet(serverApi)) {
-      options.add(ListChangesOption.REVIEWED);
-    }
-    return ListOption.toHex(options);
-  }
+  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.SKIP_DIFFSTAT);
 
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 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/index/Index.java b/java/com/google/gerrit/index/Index.java
index 44f8b42..529cd78 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -135,4 +135,12 @@
    * @param ready whether the index is ready
    */
   void markReady(boolean ready);
+
+  /**
+   * Returns whether the index is enabled. {@code true} by default, but could be overridden by
+   * implementations.
+   */
+  default boolean isEnabled() {
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 48e214e..5c003bc 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -42,7 +42,7 @@
  */
 public class InternalQuery<T, Q extends InternalQuery<T, Q>> {
   private final QueryProcessor<T> queryProcessor;
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+  protected final IndexCollection<?, T, ? extends Index<?, T>> indexes;
 
   protected final IndexConfig indexConfig;
 
diff --git a/java/com/google/gerrit/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/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 32edadb..26424d2 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,24 +14,26 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Objects.requireNonNull;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
 import java.util.List;
-import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Matcher;
 
-/** Utility functions to manipulate ChangeMessages. */
+/** Utility functions to manipulate {@link ChangeMessage}. */
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
@@ -68,39 +70,75 @@
   public static final String TAG_UPLOADED_WIP_PATCH_SET =
       AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newWipPatchSet";
 
-  public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
-    return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
+  private final AccountCache accountCache;
+
+  @Inject
+  ChangeMessagesUtil(AccountCache accountCache) {
+    this.accountCache = accountCache;
   }
 
-  public static ChangeMessage newMessage(
-      PatchSet.Id psId, CurrentUser user, Timestamp when, String body, @Nullable String tag) {
-    requireNonNull(psId);
-    Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
-    ChangeMessage m =
-        new ChangeMessage(
-            ChangeMessage.key(psId.changeId(), ChangeUtil.messageUuid()), accountId, when, psId);
-    m.setMessage(body);
-    m.setTag(tag);
-    user.updateRealAccountId(m::setRealAuthor);
-    return m;
+  /**
+   * Sets {@code messageTemplate} and {@code tag}, that should be applied by the {@code update}.
+   *
+   * <p>The {@code messageTemplate} is persisted in storage and should not contain user identifiable
+   * information. See {@link ChangeMessage}.
+   *
+   * @param update update that sets {@code messageTemplate}.
+   * @param messageTemplate message in template form, that should be applied by the update.
+   * @param tag tag that should be applied by the update.
+   * @return message built from {@code messageTemplate}. Templates are replaced, so it might contain
+   *     user identifiable information.
+   */
+  public String setChangeMessage(
+      ChangeUpdate update, String messageTemplate, @Nullable String tag) {
+    update.setChangeMessage(messageTemplate);
+    update.setTag(tag);
+    return replaceTemplates(messageTemplate);
+  }
+
+  /** See {@link #setChangeMessage(ChangeUpdate, String, String)}. */
+  public String setChangeMessage(ChangeContext ctx, String messageTemplate, @Nullable String tag) {
+    return setChangeMessage(
+        ctx.getUpdate(ctx.getChange().currentPatchSetId()), messageTemplate, tag);
   }
 
   public static String uploadedPatchSetTag(boolean workInProgress) {
     return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
   }
 
-  public List<ChangeMessage> byChange(ChangeNotes notes) {
-    return notes.load().getChangeMessages();
+  public static String getAccountTemplate(Account.Id accountId) {
+    return String.format(ChangeMessage.ACCOUNT_TEMPLATE, accountId.get());
   }
 
-  public void addChangeMessage(ChangeUpdate update, ChangeMessage changeMessage) {
-    checkState(
-        Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
-        "cannot store change message by %s in update by %s",
-        changeMessage.getAuthor(),
-        update.getNullableAccountId());
-    update.setChangeMessage(changeMessage.getMessage());
-    update.setTag(changeMessage.getTag());
+  /** Builds user-readable message from {@code messageTemplate}. See {@link ChangeMessage}. */
+  public String replaceTemplates(String messageTemplate) {
+    Matcher matcher = ChangeMessage.ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
+    StringBuffer out = new StringBuffer();
+    while (matcher.find()) {
+      String accountId = matcher.group(1);
+      String unrecognizedAccount = "Unrecognized Gerrit Account " + accountId;
+      Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
+      if (!parsedAccountId.isPresent()) {
+        matcher.appendReplacement(out, unrecognizedAccount);
+        continue;
+      }
+      Optional<AccountState> account = accountCache.get(parsedAccountId.get());
+      if (!account.isPresent()) {
+        matcher.appendReplacement(out, unrecognizedAccount);
+        continue;
+      }
+      matcher.appendReplacement(out, account.get().account().getNameEmail(unrecognizedAccount));
+    }
+    matcher.appendTail(out);
+    return out.toString();
+  }
+
+  /**
+   * Returns {@link ChangeMessage}s from {@link ChangeNotes}, loads {@link ChangeNotes} from data
+   * storage (cache or NoteDB), if it was not loaded yet.
+   */
+  public List<ChangeMessage> byChange(ChangeNotes notes) {
+    return notes.load().getChangeMessages();
   }
 
   /**
@@ -144,6 +182,23 @@
     if (realAuthor != null) {
       cmi.realAuthor = accountLoader.get(realAuthor);
     }
+    cmi.accountsInMessage =
+        message.getAccountsInMessage().stream().map(accountLoader::get).collect(toImmutableSet());
     return cmi;
   }
+
+  /**
+   * {@link ChangeMessage} is served in template form to {@link
+   * com.google.gerrit.extensions.api.changes.ChangeApi}. Serve message with replaced templates to
+   * the legacy {@link com.google.gerrit.extensions.api.changes.ChangeMessageApi} endpoints.
+   * TODO(mariasavtchouk): remove this, after {@link
+   * com.google.gerrit.extensions.api.changes.ChangeMessageApi} is deprecated (gate with
+   * experiment).
+   */
+  public ChangeMessageInfo createChangeMessageInfoWithReplacedTemplates(
+      ChangeMessage message, AccountLoader accountLoader) {
+    ChangeMessageInfo changeMessageInfo = createChangeMessageInfo(message, accountLoader);
+    changeMessageInfo.message = replaceTemplates(message.getMessage());
+    return changeMessageInfo;
+  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 358ce92..84afe8c 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
@@ -49,7 +49,6 @@
 public class PublishCommentsOp implements BatchUpdateOp {
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
@@ -57,9 +56,10 @@
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   private List<HumanComment> comments = new ArrayList<>();
-  private ChangeMessage message;
+  private String mailMessage;
   private IdentifiedUser user;
 
   public interface Factory {
@@ -69,15 +69,14 @@
   @Inject
   public PublishCommentsOp(
       ChangeNotes.Factory changeNotesFactory,
-      ChangeMessagesUtil cmUtil,
       CommentAdded commentAdded,
       CommentsUtil commentsUtil,
       EmailReviewComments.Factory email,
       PatchSetUtil psUtil,
       PublishCommentUtil publishCommentUtil,
+      ChangeMessagesUtil changeMessagesUtil,
       @Assisted PatchSet.Id psId,
       @Assisted Project.NameKey projectNameKey) {
-    this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.commentAdded = commentAdded;
     this.commentsUtil = commentsUtil;
@@ -86,6 +85,7 @@
     this.publishCommentUtil = publishCommentUtil;
     this.psUtil = psUtil;
     this.projectNameKey = projectNameKey;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   @Override
@@ -104,12 +104,12 @@
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
     publishCommentUtil.publish(ctx, changeUpdate, comments, null);
-    return insertMessage(ctx, changeUpdate);
+    return insertMessage(changeUpdate);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    if (message == null || comments.isEmpty()) {
+  public void postUpdate(PostUpdateContext ctx) {
+    if (Strings.isNullOrEmpty(mailMessage) || comments.isEmpty()) {
       return;
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
@@ -124,20 +124,30 @@
             String.format("Repository %s not found", ctx.getProject().get()), ex);
       }
       email
-          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .create(
+              notify,
+              changeNotes,
+              ps,
+              user,
+              mailMessage,
+              ctx.getWhen(),
+              comments,
+              null,
+              labelDelta,
+              repoView)
           .sendAsync();
     }
     commentAdded.fire(
-        changeNotes.getChange(),
+        ctx.getChangeData(changeNotes),
         ps,
         ctx.getAccount(),
-        message.getMessage(),
+        mailMessage,
         ImmutableMap.of(),
         ImmutableMap.of(),
         ctx.getWhen());
   }
 
-  private boolean insertMessage(ChangeContext ctx, ChangeUpdate changeUpdate) {
+  private boolean insertMessage(ChangeUpdate changeUpdate) {
     StringBuilder buf = new StringBuilder();
     if (comments.size() == 1) {
       buf.append("\n\n(1 comment)");
@@ -147,10 +157,9 @@
     if (buf.length() == 0) {
       return false;
     }
-    message =
-        ChangeMessagesUtil.newMessage(
-            psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, null);
-    cmUtil.addChangeMessage(changeUpdate, message);
+    mailMessage =
+        changeMessagesUtil.setChangeMessage(
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index e66e7f5..4acef06 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -27,11 +27,13 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
 import com.google.inject.Inject;
@@ -55,7 +57,9 @@
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
+  private final DynamicSet<EditWebLink> editLinks;
   private final DynamicSet<FileWebLink> fileLinks;
   private final DynamicSet<FileHistoryWebLink> fileHistoryLinks;
   private final DynamicSet<DiffWebLink> diffLinks;
@@ -66,7 +70,9 @@
   @Inject
   public WebLinks(
       DynamicSet<PatchSetWebLink> patchSetLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks,
       DynamicSet<ParentWebLink> parentLinks,
+      DynamicSet<EditWebLink> editLinks,
       DynamicSet<FileWebLink> fileLinks,
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
@@ -74,7 +80,9 @@
       DynamicSet<BranchWebLink> branchLinks,
       DynamicSet<TagWebLink> tagLinks) {
     this.patchSetLinks = patchSetLinks;
+    this.resolveConflictsLinks = resolveConflictsLinks;
     this.parentLinks = parentLinks;
+    this.editLinks = editLinks;
     this.fileLinks = fileLinks;
     this.fileHistoryLinks = fileLogLinks;
     this.diffLinks = diffLinks;
@@ -99,6 +107,21 @@
 
   /**
    * @param project Project name.
+   * @param revision SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
+   * @return Links for resolving comflicts.
+   */
+  public ImmutableList<WebLinkInfo> getResolveConflictsLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        resolveConflictsLinks,
+        webLink ->
+            webLink.getResolveConflictsWebLink(project.get(), commit, commitMessage, branchName));
+  }
+
+  /**
+   * @param project Project name.
    * @param revision SHA1 of the parent revision.
    * @param commitMessage the commit message of the parent revision.
    * @param branchName branch of the revision (and parent revision).
@@ -115,6 +138,18 @@
    * @param project Project name.
    * @param revision SHA1 of revision.
    * @param file File name.
+   * @return Links for editing.
+   */
+  public ImmutableList<WebLinkInfo> getEditLinks(String project, String revision, String file) {
+    return Patch.isMagic(file)
+        ? ImmutableList.of()
+        : filterLinks(editLinks, webLink -> webLink.getEditWebLink(project, revision, file));
+  }
+
+  /**
+   * @param project Project name.
+   * @param revision SHA1 of revision.
+   * @param file File name.
    * @return Links for files.
    */
   public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index e95bc1c..45f1f35 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -84,7 +84,7 @@
   private Optional<ObjectId> externalIdsRev;
   private ProjectWatches projectWatches;
   private StoredPreferences preferences;
-  private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
+  private Optional<AccountDelta> accountDelta = Optional.empty();
   private List<ValidationError> validationErrors;
 
   public AccountConfig(Account.Id accountId, AllUsersName allUsersName, Repository allUsersRepo) {
@@ -158,9 +158,9 @@
     this.loadedAccountProperties =
         Optional.of(
             new AccountProperties(account.id(), account.registeredOn(), new Config(), null));
-    this.accountUpdate =
+    this.accountDelta =
         Optional.of(
-            InternalAccountUpdate.builder()
+            AccountDelta.builder()
                 .setActive(account.isActive())
                 .setFullName(account.fullName())
                 .setDisplayName(account.displayName())
@@ -196,8 +196,8 @@
     return loadedAccountProperties.map(AccountProperties::getAccount).get();
   }
 
-  public AccountConfig setAccountUpdate(InternalAccountUpdate accountUpdate) {
-    this.accountUpdate = Optional.of(accountUpdate);
+  public AccountConfig setAccountDelta(AccountDelta accountDelta) {
+    this.accountDelta = Optional.of(accountDelta);
     return this;
   }
 
@@ -283,45 +283,44 @@
     saveProjectWatches();
     savePreferences();
 
-    accountUpdate = Optional.empty();
+    accountDelta = Optional.empty();
 
     return true;
   }
 
   private void saveAccount() throws IOException {
-    if (accountUpdate.isPresent()) {
+    if (accountDelta.isPresent()) {
       saveConfig(
-          AccountProperties.ACCOUNT_CONFIG,
-          loadedAccountProperties.get().save(accountUpdate.get()));
+          AccountProperties.ACCOUNT_CONFIG, loadedAccountProperties.get().save(accountDelta.get()));
     }
   }
 
   private void saveProjectWatches() throws IOException {
-    if (accountUpdate.isPresent()
-        && (!accountUpdate.get().getDeletedProjectWatches().isEmpty()
-            || !accountUpdate.get().getUpdatedProjectWatches().isEmpty())) {
+    if (accountDelta.isPresent()
+        && (!accountDelta.get().getDeletedProjectWatches().isEmpty()
+            || !accountDelta.get().getUpdatedProjectWatches().isEmpty())) {
       Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches =
           new HashMap<>(projectWatches.getProjectWatches());
-      accountUpdate.get().getDeletedProjectWatches().forEach(newProjectWatches::remove);
-      accountUpdate.get().getUpdatedProjectWatches().forEach(newProjectWatches::put);
+      accountDelta.get().getDeletedProjectWatches().forEach(newProjectWatches::remove);
+      accountDelta.get().getUpdatedProjectWatches().forEach(newProjectWatches::put);
       saveConfig(ProjectWatches.WATCH_CONFIG, projectWatches.save(newProjectWatches));
     }
   }
 
   private void savePreferences() throws IOException, ConfigInvalidException {
-    if (!accountUpdate.isPresent()
-        || (!accountUpdate.get().getGeneralPreferences().isPresent()
-            && !accountUpdate.get().getDiffPreferences().isPresent()
-            && !accountUpdate.get().getEditPreferences().isPresent())) {
+    if (!accountDelta.isPresent()
+        || (!accountDelta.get().getGeneralPreferences().isPresent()
+            && !accountDelta.get().getDiffPreferences().isPresent()
+            && !accountDelta.get().getEditPreferences().isPresent())) {
       return;
     }
 
     saveConfig(
         StoredPreferences.PREFERENCES_CONFIG,
         preferences.saveGeneralPreferences(
-            accountUpdate.get().getGeneralPreferences(),
-            accountUpdate.get().getDiffPreferences(),
-            accountUpdate.get().getEditPreferences()));
+            accountDelta.get().getGeneralPreferences(),
+            accountDelta.get().getDiffPreferences(),
+            accountDelta.get().getEditPreferences()));
   }
 
   private void checkLoaded() {
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/AccountDelta.java
similarity index 96%
rename from java/com/google/gerrit/server/account/InternalAccountUpdate.java
rename to java/com/google/gerrit/server/account/AccountDelta.java
index 4f9202f..fac3233 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -33,23 +33,23 @@
 import java.util.Set;
 
 /**
- * Class to prepare updates to an account.
+ * Data holder for updates to be applied to an account.
  *
- * <p>Account updates are done through {@link AccountsUpdate}. This class should be used to tell
- * {@link AccountsUpdate} how an account should be modified.
+ * <p>Instances of this type are passed to {@link AccountsUpdate}, which modifies the account
+ * accordingly.
  *
- * <p>This class allows to prepare updates of account properties, external IDs, preferences
+ * <p>Updates can be applied to account properties (name, email etc.), external IDs, preferences
  * (general, diff and edit preferences) and project watches. The account ID and the registration
  * date cannot be updated.
  *
- * <p>For the account properties there are getters in this class and the setters in the {@link
- * Builder} that correspond to the fields in {@link Account}.
+ * <p>For the account properties there are getters in this class and setters in the {@link Builder}
+ * that correspond to the fields in {@link Account}.
  */
 @AutoValue
-public abstract class InternalAccountUpdate {
+public abstract class AccountDelta {
   public static Builder builder() {
     return new Builder.WrapperThatConvertsNullStringArgsToEmptyStrings(
-        new AutoValue_InternalAccountUpdate.Builder());
+        new AutoValue_AccountDelta.Builder());
   }
 
   /**
@@ -162,13 +162,13 @@
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
   /**
-   * Class to build an account update.
+   * Class to build an {@link AccountDelta}.
    *
    * <p>Account data is only updated if the corresponding setter is invoked. If a setter is not
    * invoked the corresponding data stays unchanged. To unset string values the setter can be
    * invoked with either {@code null} or an empty string ({@code null} is converted to an empty
    * string by using the {@link WrapperThatConvertsNullStringArgsToEmptyStrings} wrapper, see {@link
-   * InternalAccountUpdate#builder()}).
+   * AccountDelta#builder()}).
    */
   @AutoValue.Builder
   public abstract static class Builder {
@@ -447,12 +447,8 @@
      */
     public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
 
-    /**
-     * Builds the account update.
-     *
-     * @return the account update
-     */
-    public abstract InternalAccountUpdate build();
+    /** Builds the instance. */
+    public abstract AccountDelta build();
 
     /**
      * Wrapper for {@link Builder} that converts {@code null} string arguments to empty strings for
@@ -525,7 +521,7 @@
       }
 
       @Override
-      public InternalAccountUpdate build() {
+      public AccountDelta build() {
         return delegate.build();
       }
 
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 47c6efb..2152e1e 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -34,14 +34,13 @@
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -221,7 +220,7 @@
   private void update(AuthRequest who, ExternalId extId)
       throws IOException, ConfigInvalidException, AccountException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
+    List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -262,7 +261,7 @@
           .update(
               "Update Account on Login",
               user.getAccountId(),
-              AccountUpdater.joinConsumers(accountUpdates))
+              AccountsUpdate.joinConsumers(accountUpdates))
           .orElseThrow(
               () -> new StorageException("Account " + user.getAccountId() + " has been deleted"));
     }
@@ -382,13 +381,13 @@
       throws IOException, ConfigInvalidException, AccountException {
     // The user initiated this request by logging in. -> Attribute all modifications to that user.
     GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(
                 memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
             .build();
     try {
-      groupsUpdate.updateGroup(groupUuid, groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, groupDelta);
     } catch (NoSuchGroupException e) {
       throw new AccountException(String.format("Group %s not found", groupUuid), e);
     }
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 5ae5567..9b7ca81 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -103,21 +103,19 @@
     account = accountBuilder.build();
   }
 
-  Config save(InternalAccountUpdate accountUpdate) {
-    writeToAccountConfig(accountUpdate, accountConfig);
+  Config save(AccountDelta accountDelta) {
+    writeToAccountConfig(accountDelta, accountConfig);
     return accountConfig;
   }
 
-  public static void writeToAccountConfig(InternalAccountUpdate accountUpdate, Config cfg) {
-    accountUpdate.getActive().ifPresent(active -> setActive(cfg, active));
-    accountUpdate.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
-    accountUpdate
-        .getDisplayName()
-        .ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
-    accountUpdate
+  public static void writeToAccountConfig(AccountDelta accountDelta, Config cfg) {
+    accountDelta.getActive().ifPresent(active -> setActive(cfg, active));
+    accountDelta.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
+    accountDelta.getDisplayName().ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
+    accountDelta
         .getPreferredEmail()
         .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail));
-    accountUpdate.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
+    accountDelta.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 1b3aa96..5a74047 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
@@ -49,6 +50,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -63,18 +65,17 @@
 /**
  * Creates and updates accounts.
  *
- * <p>This class should be used for all account updates. It supports updating account properties,
- * external IDs, preferences (general, diff and edit preferences) and project watches.
+ * <p>This class should be used for all account updates. See {@link AccountDelta} for what can be
+ * updated.
  *
- * <p>Updates to one account are always atomic. Batch updating several accounts within one
- * transaction is not supported.
+ * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
+ * #updateBatch(List)}. Batch creation is not supported.
  *
  * <p>For any account update the caller must provide a commit message, the account ID and an {@link
- * AccountUpdater}. The account updater allows to read the current {@link AccountState} and to
- * prepare updates to the account by calling setters on the provided {@link
- * InternalAccountUpdate.Builder}. If the current account state is of no interest the caller may
- * also provide a {@link Consumer} for {@link InternalAccountUpdate.Builder} instead of the account
- * updater.
+ * ConfigureDeltaFromState}. The account updater reads the current {@link AccountState} and prepares
+ * updates to the account by calling setters on the provided {@link AccountDelta.Builder}. If the
+ * current account state is of no interest the caller may also provide a {@link Consumer} for {@link
+ * AccountDelta.Builder} instead of the account updater.
  *
  * <p>The provided commit message is used for the update of the user branch. Using a precise and
  * unique commit message allows to identify the code from which an update was made when looking at a
@@ -148,37 +149,35 @@
   }
 
   /**
-   * Updater for an account.
+   * Account updates are commonly performed by evaluating the current account state and creating a
+   * delta to be applied to it in a later step. This is done by implementing this interface.
    *
-   * <p>Allows to read the current state of an account and to prepare updates to it.
+   * <p>If the current account state is not needed, use a {@link Consumer} of {@link
+   * AccountDelta.Builder} instead.
    */
   @FunctionalInterface
-  public interface AccountUpdater {
+  public interface ConfigureDeltaFromState {
     /**
-     * Prepare updates to an account.
+     * Receives the current {@link AccountState} (which is immutable) and configures an {@link
+     * AccountDelta.Builder} with changes to the account.
      *
-     * <p>Use the provided account only to read the current state of the account. Don't do updates
-     * to the account. For updates use the provided account update builder.
-     *
-     * @param accountState the account that is being updated
-     * @param update account update builder
+     * @param accountState the state of the account that is being updated
+     * @param delta the changes to be applied
      */
-    void update(AccountState accountState, InternalAccountUpdate.Builder update) throws IOException;
+    void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
+  }
 
-    static AccountUpdater join(List<AccountUpdater> updaters) {
-      return (accountState, update) -> {
-        for (AccountUpdater updater : updaters) {
-          updater.update(accountState, update);
-        }
-      };
-    }
+  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
+  public static class UpdateArguments {
+    private final String message;
+    private final Account.Id accountId;
+    private final ConfigureDeltaFromState configureDeltaFromState;
 
-    static AccountUpdater joinConsumers(List<Consumer<InternalAccountUpdate.Builder>> consumers) {
-      return join(Lists.transform(consumers, AccountUpdater::fromConsumer));
-    }
-
-    static AccountUpdater fromConsumer(Consumer<InternalAccountUpdate.Builder> consumer) {
-      return (a, u) -> consumer.accept(u);
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState) {
+      this.message = message;
+      this.accountId = accountId;
+      this.configureDeltaFromState = configureDeltaFromState;
     }
   }
 
@@ -193,13 +192,19 @@
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
 
-  // Invoked after reading the account config.
+  /** Invoked after reading the account config. */
   private final Runnable afterReadRevision;
 
-  // Invoked after updating the account but before committing the changes.
+  /** Invoked after updating the account but before committing the changes. */
   private final Runnable beforeCommit;
 
+  /** Single instance that accumulates updates from the batch. */
+  private ExternalIdNotes externalIdNotes;
+
+  private static final Runnable DO_NOTHING = () -> {};
+
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -220,11 +225,12 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -246,8 +252,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @VisibleForTesting
@@ -279,29 +285,32 @@
     this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
   }
 
+  /** Returns an instance that runs all specified consumers. */
+  public static ConfigureDeltaFromState joinConsumers(
+      List<Consumer<AccountDelta.Builder>> consumers) {
+    return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
+  }
+
+  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
+    return (a, u) -> consumer.accept(u);
+  }
+
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    if (!user.isPresent()) {
-      return serverIdent;
-    }
-    return user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+    return user.isPresent()
+        ? user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())
+        : serverIdent;
   }
 
   /**
-   * Inserts a new account.
-   *
-   * @param message commit message for the account creation, must not be {@code null or empty}
-   * @param accountId ID of the new account
-   * @param init consumer to populate the new account
-   * @return the newly created account
-   * @throws DuplicateKeyException if the account already exists
-   * @throws IOException if creating the user branch fails due to an IO error
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
+   * instead, i.e. the update does not depend on the current account state (which, for insertion,
+   * would only contain the account ID).
    */
   public AccountState insert(
-      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> init)
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
       throws IOException, ConfigInvalidException {
-    return insert(message, accountId, AccountUpdater.fromConsumer(init));
+    return insert(message, accountId, fromConsumer(init));
   }
 
   /**
@@ -309,57 +318,48 @@
    *
    * @param message commit message for the account creation, must not be {@code null or empty}
    * @param accountId ID of the new account
-   * @param updater updater to populate the new account
+   * @param init to populate the new account
    * @return the newly created account
    * @throws DuplicateKeyException if the account already exists
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public AccountState insert(String message, Account.Id accountId, AccountUpdater updater)
+  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
-    return updateAccount(
-            r -> {
-              AccountConfig accountConfig = read(r, accountId);
-              Account account =
-                  accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
-              AccountState accountState = AccountState.forAccount(account);
-              InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-              updater.update(accountState, updateBuilder);
+    return execute(
+            ImmutableList.of(
+                repo -> {
+                  AccountConfig accountConfig = read(repo, accountId);
+                  Account account =
+                      accountConfig.getNewAccount(
+                          new Timestamp(committerIdent.getWhen().getTime()));
+                  AccountState accountState = AccountState.forAccount(account);
+                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+                  init.configure(accountState, deltaBuilder);
 
-              InternalAccountUpdate update = updateBuilder.build();
-              accountConfig.setAccountUpdate(update);
-              ExternalIdNotes extIdNotes =
-                  createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
-              CachedPreferences defaultPreferences =
-                  CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+                  AccountDelta accountDelta = deltaBuilder.build();
+                  accountConfig.setAccountDelta(accountDelta);
+                  externalIdNotes =
+                      createExternalIdNotes(
+                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
+                  CachedPreferences defaultPreferences =
+                      CachedPreferences.fromConfig(
+                          VersionedDefaultPreferences.get(repo, allUsersName));
 
-              UpdatedAccount updatedAccounts =
-                  new UpdatedAccount(
-                      externalIds, message, accountConfig, extIdNotes, defaultPreferences);
-              updatedAccounts.setCreated(true);
-              return updatedAccounts;
-            })
+                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
+                }))
+        .get(0)
         .get();
   }
 
   /**
-   * Gets the account and updates it atomically.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param message commit message for the account update, must not be {@code null or empty}
-   * @param accountId ID of the account
-   * @param update consumer to update the account, only invoked if the account exists
-   * @return the updated account, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if updating the user branch fails due to an IO error
-   * @throws LockFailureException if updating the user branch still fails due to concurrent updates
-   *     after the retry timeout exceeded
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
+   * instead, i.e. the update does not depend on the current account state.
    */
   public Optional<AccountState> update(
-      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> update)
-      throws LockFailureException, IOException, ConfigInvalidException {
-    return update(message, accountId, AccountUpdater.fromConsumer(update));
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, fromConsumer(update));
   }
 
   /**
@@ -369,41 +369,77 @@
    *
    * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param updater updater to update the account, only invoked if the account exists
+   * @param configureDeltaFromState deltaBuilder to update the account, only invoked if the account
+   *     exists
    * @return the updated account, {@link Optional#empty} if the account doesn't exist
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
    *     after the retry timeout exceeded
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public Optional<AccountState> update(String message, Account.Id accountId, AccountUpdater updater)
+  public Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
       throws LockFailureException, IOException, ConfigInvalidException {
-    return updateAccount(
-        r -> {
-          AccountConfig accountConfig = read(r, accountId);
-          CachedPreferences defaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
-          Optional<AccountState> account =
-              AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
-          if (!account.isPresent()) {
-            return null;
-          }
+    return updateBatch(
+            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+        .get(0);
+  }
 
-          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-          updater.update(account.get(), updateBuilder);
+  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
+    return repo -> {
+      AccountConfig accountConfig = read(repo, updateArguments.accountId);
+      CachedPreferences defaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      Optional<AccountState> accountState =
+          AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
+      if (!accountState.isPresent()) {
+        return null;
+      }
 
-          InternalAccountUpdate update = updateBuilder.build();
-          accountConfig.setAccountUpdate(update);
-          ExternalIdNotes extIdNotes =
-              createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
-          CachedPreferences cachedDefaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
 
-          UpdatedAccount updatedAccounts =
-              new UpdatedAccount(
-                  externalIds, message, accountConfig, extIdNotes, cachedDefaultPreferences);
-          return updatedAccounts;
-        });
+      AccountDelta delta = deltaBuilder.build();
+      accountConfig.setAccountDelta(delta);
+      ExternalIdNotes.checkSameAccount(
+          Iterables.concat(
+              delta.getCreatedExternalIds(),
+              delta.getUpdatedExternalIds(),
+              delta.getDeletedExternalIds()),
+          updateArguments.accountId);
+
+      if (externalIdNotes == null) {
+        externalIdNotes =
+            extIdNotesLoader.load(
+                repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+      }
+      externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
+      externalIdNotes.upsert(delta.getUpdatedExternalIds());
+
+      CachedPreferences cachedDefaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+
+      return new UpdatedAccount(
+          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
+    };
+  }
+
+  /**
+   * Updates multiple different accounts atomically. This will only store a single new value (aka
+   * set of all external IDs of the host) in the external ID cache, which is important for storage
+   * economy. All {@code updates} must be for different accounts.
+   *
+   * <p>NOTE on error handling: Since updates are executed in multiple stages, with some stages
+   * resulting from the union of all individual updates, we cannot point to the update that caused
+   * the error. Callers should be aware that a single "update of death" (or a set of updates that
+   * together have this property) will always prevent the entire batch from being executed.
+   */
+  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+      throws IOException, ConfigInvalidException {
+    checkArgument(
+        updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
+        "updates must all be for different accounts");
+    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
   }
 
   private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
@@ -413,26 +449,35 @@
     return accountConfig;
   }
 
-  private Optional<AccountState> updateAccount(AccountUpdate accountUpdate)
+  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
       throws IOException, ConfigInvalidException {
-    return executeAccountUpdate(
+    List<Optional<AccountState>> accountState = new ArrayList<>();
+    List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+    executeWithRetry(
         () -> {
-          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-            UpdatedAccount updatedAccount = accountUpdate.update(allUsersRepo);
-            if (updatedAccount == null) {
-              return Optional.empty();
-            }
+          // Reset state for retry.
+          externalIdNotes = null;
+          accountState.clear();
+          updatedAccounts.clear();
 
-            commit(allUsersRepo, updatedAccount);
-            return Optional.of(updatedAccount.getAccount());
+          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+            for (ExecutableUpdate executableUpdate : executableUpdates) {
+              updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+            }
+            commit(
+                allUsersRepo, updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+            for (UpdatedAccount ua : updatedAccounts) {
+              accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+            }
           }
+          return null;
         });
+    return ImmutableList.copyOf(accountState);
   }
 
-  private Optional<AccountState> executeAccountUpdate(Action<Optional<AccountState>> action)
-      throws IOException, ConfigInvalidException {
+  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
     try {
-      return retryHelper.accountUpdate("updateAccount", action).call();
+      retryHelper.accountUpdate("updateAccount", action).call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
@@ -442,10 +487,7 @@
   }
 
   private ExternalIdNotes createExternalIdNotes(
-      Repository allUsersRepo,
-      Optional<ObjectId> rev,
-      Account.Id accountId,
-      InternalAccountUpdate update)
+      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     ExternalIdNotes.checkSameAccount(
         Iterables.concat(
@@ -460,73 +502,52 @@
     return extIdNotes;
   }
 
-  private void commit(Repository allUsersRepo, UpdatedAccount updatedAccount) throws IOException {
+  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
+      throws IOException {
+    if (updatedAccounts.isEmpty()) {
+      return;
+    }
+
     beforeCommit.run();
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
-    if (updatedAccount.isCreated()) {
-      commitNewAccountConfig(
-          updatedAccount.getMessage(),
-          allUsersRepo,
-          batchRefUpdate,
-          updatedAccount.getAccountConfig());
-    } else {
+    for (UpdatedAccount updatedAccount : updatedAccounts) {
+      // These updates are all for different refs (because batches never update the same account
+      // more than once), so there can be multiple commits in the same batch, all with the same base
+      // revision in their AccountConfig.
       commitAccountConfig(
-          updatedAccount.getMessage(),
+          updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
-          updatedAccount.getAccountConfig());
-    }
+          updatedAccount.accountConfig,
+          updatedAccount.created /* allowEmptyCommit */);
+      // When creating a new account we must allow empty commits so that the user branch gets
+      // created with an empty commit when no account properties are set and hence no
+      // 'account.config' file will be created.
 
-    commitExternalIdUpdates(
-        updatedAccount.getMessage(),
-        allUsersRepo,
-        batchRefUpdate,
-        updatedAccount.getExternalIdNotes());
+      // These update the same ref, so they need to be stacked on top of one another using the same
+      // ExternalIdNotes instance.
+      commitExternalIdUpdates(updatedAccount.message, allUsersRepo, batchRefUpdate);
+    }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
 
-    // Skip accounts that are updated when evicting the account cache via ExternalIdNotes to avoid
-    // double reindexing. The updated accounts will already be reindexed by ReindexAfterRefUpdate.
-    Set<Account.Id> accountsThatWillBeReindexByReindexAfterRefUpdate =
-        getUpdatedAccounts(batchRefUpdate);
-    updatedAccount
-        .getExternalIdNotes()
-        .updateCaches(accountsThatWillBeReindexByReindexAfterRefUpdate);
+    Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
+    extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
+        externalIdNotes, accountsToSkipForReindex);
 
     gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
+        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
   }
 
-  private static Set<Account.Id> getUpdatedAccounts(BatchRefUpdate batchRefUpdate) {
+  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
     return batchRefUpdate.getCommands().stream()
         .map(c -> Account.Id.fromRef(c.getRefName()))
         .filter(Objects::nonNull)
         .collect(toSet());
   }
 
-  private void commitNewAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    // When creating a new account we must allow empty commits so that the user branch gets created
-    // with an empty commit when no account properties are set and hence no 'account.config' file
-    // will be created.
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, true);
-  }
-
-  private void commitAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, false);
-  }
-
   private void commitAccountConfig(
       String message,
       Repository allUsersRepo,
@@ -541,13 +562,9 @@
   }
 
   private void commitExternalIdUpdates(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      ExternalIdNotes extIdNotes)
-      throws IOException {
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      extIdNotes.commit(md);
+      externalIdNotes.commit(md);
     }
   }
 
@@ -566,57 +583,31 @@
   }
 
   @FunctionalInterface
-  private static interface AccountUpdate {
-    UpdatedAccount update(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+  private interface ExecutableUpdate {
+    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
   }
 
-  private static class UpdatedAccount {
-    private final ExternalIds externalIds;
-    private final String message;
-    private final AccountConfig accountConfig;
-    private final ExternalIdNotes extIdNotes;
-    private final CachedPreferences defaultPreferences;
+  private class UpdatedAccount {
+    final String message;
+    final AccountConfig accountConfig;
+    final CachedPreferences defaultPreferences;
+    final boolean created;
 
-    private boolean created;
-
-    private UpdatedAccount(
-        ExternalIds externalIds,
+    UpdatedAccount(
         String message,
         AccountConfig accountConfig,
-        ExternalIdNotes extIdNotes,
-        CachedPreferences defaultPreferences) {
+        CachedPreferences defaultPreferences,
+        boolean created) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.externalIds = requireNonNull(externalIds);
       this.message = requireNonNull(message);
       this.accountConfig = requireNonNull(accountConfig);
-      this.extIdNotes = requireNonNull(extIdNotes);
       this.defaultPreferences = defaultPreferences;
-    }
-
-    public String getMessage() {
-      return message;
-    }
-
-    public AccountConfig getAccountConfig() {
-      return accountConfig;
-    }
-
-    public AccountState getAccount() throws IOException {
-      return AccountState.fromAccountConfig(
-              externalIds, accountConfig, extIdNotes, defaultPreferences)
-          .get();
-    }
-
-    public ExternalIdNotes getExternalIdNotes() {
-      return extIdNotes;
-    }
-
-    public void setCreated(boolean created) {
       this.created = created;
     }
 
-    public boolean isCreated() {
-      return created;
+    Optional<AccountState> getAccountState() throws IOException {
+      return AccountState.fromAccountConfig(
+          externalIds, accountConfig, externalIdNotes, defaultPreferences);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 92e7c71..2231519 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -22,7 +22,8 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * Caches external IDs of all accounts.
+ * Caches external IDs of all accounts. Note that the granularity is "revision" only, so each update
+ * will cache a new value containing <b>all</b> external IDs.
  *
  * <p>On each cache access the SHA1 of the refs/meta/external-ids branch is read to verify that the
  * cache is up to date.
@@ -30,6 +31,15 @@
  * <p>All returned collections are unmodifiable.
  */
 interface ExternalIdCache {
+
+  /**
+   * Updates the cache.
+   *
+   * @param oldNotesRev current revision against which the below updates are applied
+   * @param newNotesRev key for the new cache revision
+   * @param toRemove external IDs to remove
+   * @param toAdd external IDs to add
+   */
   void onReplace(
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index e999c93..e403a5b 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -86,21 +85,34 @@
  * <p>On save the staged external ID updates are performed (see {@link #onSave(CommitBuilder)}).
  *
  * <p>After committing the external IDs a cache update can be requested which also reindexes the
- * accounts for which external IDs have been updated (see {@link #updateCaches()}).
+ * accounts for which external IDs have been updated (see {@link
+ * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts)}).
  */
 public class ExternalIdNotes extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final int MAX_NOTE_SZ = 1 << 19;
 
-  public interface ExternalIdNotesLoader {
+  public abstract static class ExternalIdNotesLoader {
+    protected final ExternalIdCache externalIdCache;
+    protected final MetricMaker metricMaker;
+    protected final AllUsersName allUsersName;
+
+    protected ExternalIdNotesLoader(
+        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
+      this.externalIdCache = externalIdCache;
+      this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
+    }
+
     /**
      * Loads the external ID notes from the current tip of the {@code refs/meta/external-ids}
      * branch.
      *
      * @param allUsersRepo the All-Users repository
      */
-    ExternalIdNotes load(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+    public abstract ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException;
 
     /**
      * Loads the external ID notes from the specified revision of the {@code refs/meta/external-ids}
@@ -112,107 +124,122 @@
      *     assumed that the {@code refs/meta/external-ids} branch doesn't exist and the loaded
      *     external IDs will be empty
      */
-    ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
+    public abstract ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException;
+
+    /**
+     * Updates the external ID cache. Subclasses of type {@link Factory} will also reindex the
+     * accounts for which external IDs were modified, while subclasses of type {@link
+     * FactoryNoReindex} will skip this.
+     *
+     * <p>Must only be called after committing changes.
+     *
+     * @param externalIdNotes the committed updates that should be applied to the cache. This first
+     *     and last element must be the updates commited first and last, respectively.
+     * @param accountsToSkipForReindex accounts that should not be reindexed. This is to avoid
+     *     double reindexing when updated accounts will already be reindexed by
+     *     ReindexAfterRefUpdate.
+     */
+    public void updateExternalIdCacheAndMaybeReindexAccounts(
+        ExternalIdNotes externalIdNotes, Collection<Account.Id> accountsToSkipForReindex)
+        throws IOException {
+      checkState(externalIdNotes.oldRev != null, "no changes committed yet");
+
+      // readOnly is ignored here (legacy behavior).
+
+      // Aggregate all updates.
+      ExternalIdCacheUpdates updates = new ExternalIdCacheUpdates();
+      for (CacheUpdate cacheUpdate : externalIdNotes.cacheUpdates) {
+        cacheUpdate.execute(updates);
+      }
+
+      // Perform the cache update.
+      if (!externalIdNotes.noCacheUpdate) {
+        // Regardless of noCacheUpdate it's still possible that the ExternalIdCache instance is of
+        // type DisabledExternalIdCache, making this call a no-op.
+        externalIdCache.onReplace(
+            externalIdNotes.oldRev,
+            externalIdNotes.getRevision(),
+            updates.getRemoved(),
+            updates.getAdded());
+      }
+
+      // Reindex accounts (if the subclass implements reindexAccount()).
+      if (!externalIdNotes.noReindex) {
+        Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream())
+            .map(ExternalId::accountId)
+            .filter(i -> !accountsToSkipForReindex.contains(i))
+            .distinct()
+            .forEach(this::reindexAccount);
+      }
+
+      // Reset instance state.
+      externalIdNotes.cacheUpdates.clear();
+      externalIdNotes.keysToAdd.clear();
+      externalIdNotes.oldRev = null;
+    }
+
+    protected abstract void reindexAccount(Account.Id id);
   }
 
   @Singleton
-  public static class Factory implements ExternalIdNotesLoader {
-    private final ExternalIdCache externalIdCache;
-    private final AccountCache accountCache;
+  public static class Factory extends ExternalIdNotesLoader {
+
     private final Provider<AccountIndexer> accountIndexer;
-    private final MetricMaker metricMaker;
-    private final AllUsersName allUsersName;
 
     @Inject
     Factory(
         ExternalIdCache externalIdCache,
-        AccountCache accountCache,
         Provider<AccountIndexer> accountIndexer,
         MetricMaker metricMaker,
         AllUsersName allUsersName) {
-      this.externalIdCache = externalIdCache;
-      this.accountCache = accountCache;
+      super(externalIdCache, metricMaker, allUsersName);
       this.accountIndexer = accountIndexer;
-      this.metricMaker = metricMaker;
-      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache,
-              accountCache,
-              accountIndexer,
-              metricMaker,
-              allUsersName,
-              allUsersRepo)
-          .load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache,
-              accountCache,
-              accountIndexer,
-              metricMaker,
-              allUsersName,
-              allUsersRepo)
-          .load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load(rev);
+    }
+
+    @Override
+    protected void reindexAccount(Account.Id id) {
+      accountIndexer.get().index(id);
     }
   }
 
   @Singleton
-  public static class FactoryNoReindex implements ExternalIdNotesLoader {
-    private final ExternalIdCache externalIdCache;
-    private final MetricMaker metricMaker;
-    private final AllUsersName allUsersName;
+  public static class FactoryNoReindex extends ExternalIdNotesLoader {
 
     @Inject
     FactoryNoReindex(
         ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
-      this.externalIdCache = externalIdCache;
-      this.metricMaker = metricMaker;
-      this.allUsersName = allUsersName;
+      super(externalIdCache, metricMaker, allUsersName);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
-          .load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(
-              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
-          .load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load(rev);
     }
-  }
 
-  /**
-   * Loads the external ID notes for reading only. The external ID notes are loaded from the current
-   * tip of the {@code refs/meta/external-ids} branch.
-   *
-   * @return read-only {@link ExternalIdNotes} instance
-   */
-  public static ExternalIdNotes loadReadOnly(AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
-        .setReadOnly()
-        .load();
+    @Override
+    protected void reindexAccount(Account.Id id) {
+      // Do not reindex.
+    }
   }
 
   /**
@@ -228,14 +255,10 @@
   public static ExternalIdNotes loadReadOnly(
       AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
+    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
         .setReadOnly()
+        .setNoCacheUpdate()
+        .setNoReindex()
         .load(rev);
   }
 
@@ -252,19 +275,12 @@
   public static ExternalIdNotes loadNoCacheUpdate(
       AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
+    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
+        .setNoCacheUpdate()
+        .setNoReindex()
         .load();
   }
 
-  private final ExternalIdCache externalIdCache;
-  @Nullable private final AccountCache accountCache;
-  @Nullable private final Provider<AccountIndexer> accountIndexer;
   private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
@@ -273,25 +289,30 @@
   private NoteMap noteMap;
   private ObjectId oldRev;
 
-  // Staged note map updates that should be executed on save.
-  private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+  /** Staged note map updates that should be executed on save. */
+  private final List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
 
-  // Staged cache updates that should be executed after external ID changes have been committed.
-  private List<CacheUpdate> cacheUpdates = new ArrayList<>();
+  /** Staged cache updates that should be executed after external ID changes have been committed. */
+  private final List<CacheUpdate> cacheUpdates = new ArrayList<>();
+
+  /**
+   * When performing batch updates (cf. {@link AccountsUpdate#updateBatch(List)} we need to ensure
+   * the batch does not introduce duplicates. In addition to checking against the status quo in
+   * {@link #noteMap} (cf. {@link #checkExternalIdKeysDontExist(Collection)}), which is sufficient
+   * for single updates, we also need to check for duplicates among the batch updates. As the actual
+   * updates are computed lazily just before applying them, we unfortunately need to track keys
+   * explicitly here even though they are already implicit in the lambdas that constitute the
+   * updates.
+   */
+  private final Set<ExternalId.Key> keysToAdd = new HashSet<>();
 
   private Runnable afterReadRevision;
   private boolean readOnly = false;
+  private boolean noCacheUpdate = false;
+  private boolean noReindex = false;
 
   private ExternalIdNotes(
-      ExternalIdCache externalIdCache,
-      @Nullable AccountCache accountCache,
-      @Nullable Provider<AccountIndexer> accountIndexer,
-      MetricMaker metricMaker,
-      AllUsersName allUsersName,
-      Repository allUsersRepo) {
-    this.externalIdCache = requireNonNull(externalIdCache, "externalIdCache");
-    this.accountCache = accountCache;
-    this.accountIndexer = accountIndexer;
+      MetricMaker metricMaker, AllUsersName allUsersName, Repository allUsersRepo) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
@@ -319,7 +340,17 @@
   }
 
   private ExternalIdNotes setReadOnly() {
-    this.readOnly = true;
+    readOnly = true;
+    return this;
+  }
+
+  private ExternalIdNotes setNoCacheUpdate() {
+    noCacheUpdate = true;
+    return this;
+  }
+
+  private ExternalIdNotes setNoReindex() {
+    noReindex = true;
     return this;
   }
 
@@ -463,6 +494,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.add(newExtIds));
+    incrementalDuplicateDetection(extIds);
   }
 
   /**
@@ -491,6 +523,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds));
+    incrementalDuplicateDetection(extIds);
   }
 
   /**
@@ -605,6 +638,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+    incrementalDuplicateDetection(toAdd);
   }
 
   /**
@@ -637,6 +671,7 @@
           }
         });
     cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+    incrementalDuplicateDetection(toAdd);
   }
 
   /**
@@ -695,66 +730,6 @@
     return commit;
   }
 
-  /**
-   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
-   * external IDs were modified.
-   *
-   * <p>Must only be called after committing changes.
-   *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
-   *
-   * <p>No eviction from account cache and no reindex if this instance was created by {@link
-   * FactoryNoReindex}.
-   */
-  public void updateCaches() throws IOException {
-    updateCaches(ImmutableSet.of());
-  }
-
-  /**
-   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
-   * external IDs were modified.
-   *
-   * <p>Must only be called after committing changes.
-   *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
-   *
-   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
-   *
-   * @param accountsToSkip set of accounts that should not be evicted from the account cache, in
-   *     this case the caller must take care to evict them otherwise
-   */
-  public void updateCaches(Collection<Account.Id> accountsToSkip) throws IOException {
-    checkState(oldRev != null, "no changes committed yet");
-
-    ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
-    for (CacheUpdate cacheUpdate : cacheUpdates) {
-      cacheUpdate.execute(externalIdCacheUpdates);
-    }
-
-    externalIdCache.onReplace(
-        oldRev,
-        getRevision(),
-        externalIdCacheUpdates.getRemoved(),
-        externalIdCacheUpdates.getAdded());
-
-    if (accountCache != null || accountIndexer != null) {
-      for (Account.Id id :
-          Streams.concat(
-                  externalIdCacheUpdates.getAdded().stream(),
-                  externalIdCacheUpdates.getRemoved().stream())
-              .map(ExternalId::accountId)
-              .filter(i -> !accountsToSkip.contains(i))
-              .collect(toSet())) {
-        if (accountIndexer != null) {
-          accountIndexer.get().index(id);
-        }
-      }
-    }
-
-    cacheUpdates.clear();
-    oldRev = null;
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkState(!readOnly, "Updating external IDs is disabled");
@@ -829,6 +804,17 @@
     return accountId;
   }
 
+  private void incrementalDuplicateDetection(Collection<ExternalId> externalIds) {
+    externalIds.stream()
+        .map(ExternalId::key)
+        .forEach(
+            key -> {
+              if (!keysToAdd.add(key)) {
+                throw new DuplicateExternalIdKeyException(key);
+              }
+            });
+  }
+
   /**
    * Insert or updates an new external ID and sets it in the note map.
    *
@@ -866,12 +852,11 @@
    * @throws IllegalStateException is thrown if there is an existing external ID that has the same
    *     key, but otherwise doesn't match the specified external ID.
    */
-  private static ExternalId remove(
-      RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
+  private static void remove(RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     if (!noteMap.contains(noteId)) {
-      return null;
+      return;
     }
 
     ObjectId noteDataId = noteMap.get(noteId);
@@ -884,7 +869,6 @@
         actualExtId.toString());
     noteMap.remove(noteId);
     addFooters(footers, actualExtId);
-    return actualExtId;
   }
 
   /**
@@ -969,15 +953,15 @@
   }
 
   private static class ExternalIdCacheUpdates {
-    private final Set<ExternalId> added = new HashSet<>();
-    private final Set<ExternalId> removed = new HashSet<>();
+    final Set<ExternalId> added = new HashSet<>();
+    final Set<ExternalId> removed = new HashSet<>();
 
     ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
       this.added.addAll(extIds);
       return this;
     }
 
-    public Set<ExternalId> getAdded() {
+    Set<ExternalId> getAdded() {
       return ImmutableSet.copyOf(added);
     }
 
@@ -986,7 +970,7 @@
       return this;
     }
 
-    public Set<ExternalId> getRemoved() {
+    Set<ExternalId> getRemoved() {
       return ImmutableSet.copyOf(removed);
     }
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index c055313..f2505fa 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -106,7 +106,7 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo).all();
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null).all();
     }
   }
 
@@ -135,7 +135,7 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo).get(key);
+      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null).get(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 815f7d0..6a4da09 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -58,7 +58,7 @@
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null));
     }
   }
 
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0b340b8..6a26f53 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -21,8 +21,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -39,6 +37,8 @@
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -467,7 +467,7 @@
   }
 
   @Override
-  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+  public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException {
     try {
       return postReviewers.apply(change, in).value();
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 573f2f5..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..10e1f92 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -18,7 +18,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -32,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -50,7 +49,7 @@
 
   private Change change;
   private PatchSet patchSet;
-  private ChangeMessage message;
+  private String mailMessage;
 
   public interface Factory {
     AbandonOp create(
@@ -94,24 +93,22 @@
     change.setLastUpdatedOn(ctx.getWhen());
 
     update.setStatus(change.getStatus());
-    message = newMessage(ctx);
-    cmUtil.addChangeMessage(update, message);
+    mailMessage = cmUtil.setChangeMessage(ctx, commentMessage(), ChangeMessagesUtil.TAG_ABANDON);
     return true;
   }
 
-  private ChangeMessage newMessage(ChangeContext ctx) {
+  private String commentMessage() {
     StringBuilder msg = new StringBuilder();
     msg.append("Abandoned");
     if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
       msg.append("\n\n");
       msg.append(msgTxt.trim());
     }
-
-    return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
+    return msg.toString();
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender emailSender =
@@ -119,7 +116,7 @@
       if (accountState != null) {
         emailSender.setFrom(accountState.account().id());
       }
-      emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
       emailSender.setNotify(notify);
       emailSender.setMessageId(
           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
@@ -127,6 +124,12 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
-    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
+    changeAbandoned.fire(
+        ctx.getChangeData(change),
+        patchSet,
+        accountState,
+        msgTxt,
+        ctx.getWhen(),
+        notify.handling());
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index ff8e5c6..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..c067fcb 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -18,7 +18,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
-import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
@@ -32,7 +32,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
@@ -50,9 +49,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 +73,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.InsertChangeOp;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -112,7 +112,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final AutoMerger autoMerger;
@@ -138,17 +138,17 @@
   private boolean sendMail;
   private boolean updateRef;
   private Change.Id revertOf;
-  private ImmutableList<InternalAddReviewerInput> reviewerInputs;
+  private ImmutableList<InternalReviewerInput> reviewerInputs;
 
   // Fields set during the insertion process.
   private ReceiveCommand cmd;
   private Change change;
-  private ChangeMessage changeMessage;
+  private String changeMessage;
   private PatchSetInfo patchSetInfo;
   private PatchSet patchSet;
   private String pushCert;
   private ProjectState projectState;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
 
   @Inject
   ChangeInserter(
@@ -163,7 +163,7 @@
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       AutoMerger autoMerger,
@@ -181,7 +181,7 @@
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
     this.autoMerger = autoMerger;
@@ -280,8 +280,8 @@
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newAddReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newAddReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
+                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
             .collect(toImmutableList());
     return this;
   }
@@ -361,7 +361,7 @@
     return this;
   }
 
-  public ChangeMessage getChangeMessage() {
+  public String getChangeMessage() {
     if (message == null) {
       return null;
     }
@@ -443,8 +443,9 @@
     }
 
     reviewerAdditions =
-        reviewerAdder.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
-    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+        reviewerModifier.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
+    Optional<ReviewerModification> reviewerError =
+        reviewerAdditions.getFailures().stream().findFirst();
     if (reviewerError.isPresent()) {
       throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
@@ -463,19 +464,14 @@
     }
     if (message != null) {
       changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.id(),
-              ctx.getUser(),
-              patchSet.createdOn(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-      cmUtil.addChangeMessage(update, changeMessage);
+          cmUtil.setChangeMessage(
+              update, message, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
     }
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (sendMail && notify.shouldNotify()) {
@@ -528,7 +524,8 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+      revisionCreated.fire(
+          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
         List<LabelType> labels = projectState.getLabelTypes(change.getDest()).getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
@@ -544,7 +541,13 @@
           }
         }
         commentAdded.fire(
-            change, patchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
+            ctx.getChangeData(change),
+            patchSet,
+            ctx.getAccount(),
+            null,
+            allApprovals,
+            oldApprovals,
+            ctx.getWhen());
       }
     }
   }
@@ -580,35 +583,34 @@
     }
   }
 
-  private static InternalAddReviewerInput newAddReviewerInput(
-      String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
-    InternalAddReviewerInput input =
-        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+    InternalReviewerInput input =
+        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. This is required for the push path, where it automatically sets reviewers from
     // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
     // theory we could provide finer control to do this for some reviewers and not others, but it's
     // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
 
     return input;
   }
 
-  private ImmutableList<InternalAddReviewerInput> getReviewerInputs() {
+  private ImmutableList<InternalReviewerInput> getReviewerInputs() {
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getAuthor().getAccount(),
                     NotifyHandling.NONE,
                     change.getOwner())),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getCommitter().getAccount(),
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 6f2e1ef..787f036 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -31,6 +31,7 @@
   public String reviewerInvalid;
   public String reviewerNotFoundUserOrGroup;
 
+  public String groupRemovalIsNotAllowed;
   public String groupIsNotAllowed;
   public String groupHasTooManyMembers;
   public String groupManyMembersConfirmation;
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 14298d5..c7ddf19 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -49,6 +50,7 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeDeleted changeDeleted;
   private final Change.Id id;
 
@@ -57,11 +59,13 @@
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
       PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      ChangeData.Factory changeDataFactory,
       ChangeDeleted changeDeleted,
       @Assisted Change.Id id) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeDataFactory = changeDataFactory;
     this.changeDeleted = changeDeleted;
     this.id = id;
   }
@@ -90,7 +94,7 @@
                     .map(p -> p.commitId().name())
                     .orElse("n/a")));
     ctx.deleteChange();
-    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
+    changeDeleted.fire(changeDataFactory.create(ctx.getChange()), ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 255e13a..b512a2d 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -17,19 +17,18 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collections;
 
-public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+public class DeleteReviewerByEmailOp extends ReviewerOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -38,19 +37,21 @@
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final MessageIdGenerator messageIdGenerator;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   private final Address reviewer;
-
-  private ChangeMessage changeMessage;
+  private String mailMessage;
   private Change change;
 
   @Inject
   DeleteReviewerByEmailOp(
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       MessageIdGenerator messageIdGenerator,
+      ChangeMessagesUtil changeMessagesUtil,
       @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.messageIdGenerator = messageIdGenerator;
+    this.changeMessagesUtil = changeMessagesUtil;
     this.reviewer = reviewer;
   }
 
@@ -58,38 +59,38 @@
   public boolean updateChange(ChangeContext ctx) {
     change = ctx.getChange();
     PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    update.removeReviewerByEmail(reviewer);
+    // The reviewer is not a registered Gerrit user, thus the email address can be used in
+    // ChangeMessage without replacement (it does not classify as Gerrit user identifiable
+    // information).
     String msg = "Removed reviewer " + reviewer;
-    changeMessage =
-        new ChangeMessage(
-            ChangeMessage.key(change.getId(), ChangeUtil.messageUuid()),
-            ctx.getAccountId(),
-            ctx.getWhen(),
-            psId);
-    changeMessage.setMessage(msg);
-
-    ctx.getUpdate(psId).setChangeMessage(msg);
-    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    mailMessage =
+        changeMessagesUtil.setChangeMessage(ctx, msg, ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    try {
-      NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      if (!notify.shouldNotify()) {
-        return;
+  public void postUpdate(PostUpdateContext ctx) {
+    opResult = Result.builder().setDeletedReviewerByEmail(reviewer).build();
+    if (sendEmail) {
+      try {
+        NotifyResolver.Result notify = ctx.getNotify(change.getId());
+        if (!notify.shouldNotify()) {
+          return;
+        }
+        DeleteReviewerSender emailSender =
+            deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.addReviewersByEmail(Collections.singleton(reviewer));
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
       }
-      DeleteReviewerSender emailSender =
-          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      emailSender.setFrom(ctx.getAccountId());
-      emailSender.addReviewersByEmail(Collections.singleton(reviewer));
-      emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index bf00d27..64472ea 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -20,10 +20,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
@@ -35,6 +33,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,42 +43,41 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
-public class DeleteReviewerOp implements BatchUpdateOp {
+public class DeleteReviewerOp extends ReviewerOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    DeleteReviewerOp create(AccountState reviewerAccount, DeleteReviewerInput input);
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
   }
 
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
+  private final AccountCache accountCache;
 
-  private final AccountState reviewer;
+  private final Account reviewer;
   private final DeleteReviewerInput input;
 
-  ChangeMessage changeMessage;
+  String mailMessage;
   Change currChange;
-  PatchSet currPs;
   Map<String, Short> newApprovals = new HashMap<>();
   Map<String, Short> oldApprovals = new HashMap<>();
 
@@ -88,25 +86,25 @@
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
-      @Assisted AccountState reviewerAccount,
+      AccountCache accountCache,
+      @Assisted Account reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
     this.reviewerDeleted = reviewerDeleted;
     this.user = user;
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.accountCache = accountCache;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -114,15 +112,18 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
-    Account.Id reviewerId = reviewer.account().id();
+    Account.Id reviewerId = reviewer.id();
     // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
     removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
 
     if (!approvalsUtil.getReviewers(ctx.getNotes()).all().contains(reviewerId)) {
-      throw new ResourceNotFoundException();
+      throw new ResourceNotFoundException(
+          String.format(
+              "Reviewer %s doesn't exist in the change, hence can't delete it",
+              reviewer.getName()));
     }
     currChange = ctx.getChange();
-    currPs = psUtil.current(ctx.getNotes());
+    setPatchSet(psUtil.current(ctx.getNotes()));
 
     LabelTypes labelTypes =
         projectCache
@@ -141,21 +142,23 @@
             ? "cc"
             : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
+    msg.append(
+        String.format(
+            "Removed %s %s", ccOrReviewer, ChangeMessagesUtil.getAccountTemplate(reviewer.id())));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
     for (PatchSetApproval a : approvals(ctx, reviewerId)) {
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
+      if (a.patchSetId().equals(patchSet.id()) && a.value() != 0) {
         oldApprovals.put(a.label(), a.value());
         removedVotesMsg
             .append("* ")
             .append(a.label())
             .append(formatLabelValue(a.value()))
             .append(" by ")
-            .append(userFactory.create(a.accountId()).getNameEmail())
+            .append(ChangeMessagesUtil.getAccountTemplate(a.accountId()))
             .append("\n");
         votesRemoved = true;
       }
@@ -166,40 +169,44 @@
     } else {
       msg.append(".");
     }
-    ChangeUpdate update = ctx.getUpdate(currPs.id());
+    ChangeUpdate update = ctx.getUpdate(patchSet.id());
     update.removeReviewer(reviewerId);
 
-    changeMessage =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-    cmUtil.addChangeMessage(update, changeMessage);
-
+    mailMessage =
+        cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
+    opResult = Result.builder().setDeletedReviewer(reviewer.id()).build();
+
     NotifyResolver.Result notify = ctx.getNotify(currChange.getId());
-    if (input.notify == null
-        && currChange.isWorkInProgress()
-        && !oldApprovals.isEmpty()
-        && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
-      // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
-      // change.
-      notify = notify.withHandling(NotifyHandling.OWNER);
-    }
-    try {
-      if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
+    if (sendEmail) {
+      if (input.notify == null
+          && currChange.isWorkInProgress()
+          && !oldApprovals.isEmpty()
+          && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
+        // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
+        // change.
+        notify = notify.withHandling(NotifyHandling.OWNER);
       }
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
+      try {
+        if (notify.shouldNotify()) {
+          emailReviewers(
+              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
+        }
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot email update for change %s", currChange.getId());
+      }
     }
     reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
+        ctx.getChangeData(currChange),
+        patchSet,
+        accountCache.get(reviewer.id()).orElse(AccountState.forAccount(reviewer)),
         ctx.getAccount(),
-        changeMessage.getMessage(),
+        mailMessage,
         newApprovals,
         oldApprovals,
         notify.handling(),
@@ -222,20 +229,21 @@
   private void emailReviewers(
       Project.NameKey projectName,
       Change change,
-      ChangeMessage changeMessage,
+      String mailMessage,
+      Timestamp timestamp,
       NotifyResolver.Result notify,
       RepoView repoView)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
-    if (userId.equals(reviewer.account().id())) {
+    if (userId.equals(reviewer.id())) {
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
     DeleteReviewerSender emailSender =
         deleteReviewerSenderFactory.create(projectName, change.getId());
     emailSender.setFrom(userId);
-    emailSender.addReviewers(Collections.singleton(reviewer.account().id()));
-    emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+    emailSender.addReviewers(Collections.singleton(reviewer.id()));
+    emailSender.setChangeMessage(mailMessage, timestamp);
     emailSender.setNotify(notify);
     emailSender.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
new file mode 100644
index 0000000..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/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index cacfbe7..d433c4e 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.CurrentUser;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -49,9 +49,9 @@
      * @param notes change notes.
      * @param patchSet patch set corresponding to the top-level op
      * @param user user the email should come from.
-     * @param message used by text template only: the full ChangeMessage that will go in the
-     *     database. The contents of this message typically include the "Patch set N" header and "(M
-     *     comments)".
+     * @param message used by text template only. The contents of this message typically include the
+     *     "Patch set N" header and "(M comments)".
+     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -64,9 +64,10 @@
         ChangeNotes notes,
         PatchSet patchSet,
         IdentifiedUser user,
-        ChangeMessage message,
+        @Assisted("message") String message,
+        Timestamp timestamp,
         List<? extends Comment> comments,
-        String patchSetComment,
+        @Assisted("patchSetComment") String patchSetComment,
         List<LabelVote> labels,
         RepoView repoView);
   }
@@ -81,7 +82,8 @@
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
-  private final ChangeMessage message;
+  private final String message;
+  private final Timestamp timestamp;
   private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
@@ -98,9 +100,10 @@
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
-      @Assisted ChangeMessage message,
+      @Assisted("message") String message,
+      @Assisted Timestamp timestamp,
       @Assisted List<? extends Comment> comments,
-      @Nullable @Assisted String patchSetComment,
+      @Nullable @Assisted("patchSetComment") String patchSetComment,
       @Assisted List<LabelVote> labels,
       @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
@@ -113,6 +116,7 @@
     this.patchSet = patchSet;
     this.user = user;
     this.message = message;
+    this.timestamp = timestamp;
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
@@ -132,7 +136,7 @@
           commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
       emailSender.setFrom(user.getAccountId());
       emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message.getMessage(), message.getWrittenOn());
+      emailSender.setChangeMessage(message, timestamp);
       emailSender.setComments(comments);
       emailSender.setPatchSetComment(patchSetComment);
       emailSender.setLabels(labels);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
index 228d631..a926147 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
@@ -155,6 +155,13 @@
       return;
     }
     metrics.diffs.increment(Metrics.Status.MISMATCH);
-    logger.atWarning().log(warningMessage);
+    logger.atWarning().log(
+        warningMessage
+            + "\n"
+            + "Result using old impl: "
+            + fileInfoMapOld
+            + "\n"
+            + "Result using new impl: "
+            + fileInfoMapNew);
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
similarity index 80%
rename from java/com/google/gerrit/server/change/AddReviewersEmail.java
rename to java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index 4a3f638..cb747f6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ModifyReviewerSender;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -33,16 +33,16 @@
 import java.util.concurrent.Future;
 
 @Singleton
-public class AddReviewersEmail {
+public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
-  AddReviewersEmail(
-      AddReviewerSender.Factory addReviewerSenderFactory,
+  ModifyReviewersEmail(
+      ModifyReviewerSender.Factory addReviewerSenderFactory,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
@@ -55,19 +55,25 @@
       Change change,
       Collection<Account.Id> added,
       Collection<Account.Id> copied,
+      Collection<Account.Id> removed,
       Collection<Address> addedByEmail,
       Collection<Address> copiedByEmail,
+      Collection<Address> removedByEmail,
       NotifyResolver.Result notify) {
-    // The user knows they added themselves, don't bother emailing them.
+    // The user knows they added/removed themselves, don't bother emailing them.
     Account.Id userId = user.getAccountId();
     ImmutableList<Account.Id> immutableToMail =
         added.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
     ImmutableList<Account.Id> immutableToCopy =
         copied.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
+    ImmutableList<Account.Id> immutableToRemove =
+        removed.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
     if (immutableToMail.isEmpty()
         && immutableToCopy.isEmpty()
+        && immutableToRemove.isEmpty()
         && addedByEmail.isEmpty()
-        && copiedByEmail.isEmpty()) {
+        && copiedByEmail.isEmpty()
+        && removedByEmail.isEmpty()) {
       return;
     }
 
@@ -77,13 +83,14 @@
     Project.NameKey projectNameKey = change.getProject();
     ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail);
     ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail);
+    ImmutableList<Address> immutableRemovedByEmail = ImmutableList.copyOf(removedByEmail);
 
     @SuppressWarnings("unused")
     Future<?> possiblyIgnoredError =
         sendEmailsExecutor.submit(
             () -> {
               try {
-                AddReviewerSender emailSender =
+                ModifyReviewerSender emailSender =
                     addReviewerSenderFactory.create(projectNameKey, cId);
                 emailSender.setNotify(notify);
                 emailSender.setFrom(userId);
@@ -91,6 +98,8 @@
                 emailSender.addReviewersByEmail(immutableAddedByEmail);
                 emailSender.addExtraCC(immutableToCopy);
                 emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.addRemovedReviewers(immutableToRemove);
+                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
                 emailSender.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d2bf3fe..647fdf0 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -23,7 +23,6 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -53,7 +52,7 @@
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -110,7 +109,7 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
-  private ChangeMessage changeMessage;
+  private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
 
@@ -260,14 +259,9 @@
     }
 
     if (message != null) {
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.id(),
-              ctx.getUser(),
-              ctx.getWhen(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-      changeMessage.setMessage(message);
+      mailMessage =
+          cmUtil.setChangeMessage(
+              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
     }
 
     oldWorkInProgressState = change.isWorkInProgress();
@@ -283,9 +277,6 @@
       change.setStatus(Change.Status.NEW);
     }
     change.setCurrentPatchSet(patchSetInfo);
-    if (changeMessage != null) {
-      cmUtil.addChangeMessage(update, changeMessage);
-    }
     if (topic != null) {
       change.setTopic(topic);
       try {
@@ -298,16 +289,16 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(changeMessage);
+      requireNonNull(mailMessage);
       try {
         ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
         emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.addReviewers(oldReviewers.byState(REVIEWER));
         emailSender.addExtraCC(oldReviewers.byState(CC));
         emailSender.setNotify(notify);
@@ -321,11 +312,12 @@
     }
 
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+      revisionCreated.fire(
+          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
 
     if (workInProgress != null && oldWorkInProgressState != workInProgress) {
-      wipStateChanged.fire(change, patchSet, ctx.getAccount(), ctx.getWhen());
+      wipStateChanged.fire(ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index b43996e..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 b702440..33f3d4f 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -182,9 +182,14 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links =
+      ImmutableList<WebLinkInfo> patchSetLinks =
           webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
-      info.webLinks = links.isEmpty() ? null : links;
+      info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
+      ImmutableList<WebLinkInfo> resolveConflictsLinks =
+          webLinks.getResolveConflictsLinks(
+              project, commit.name(), commit.getFullMessage(), branchName);
+      info.resolveConflictsWebLinks =
+          resolveConflictsLinks.isEmpty() ? null : resolveConflictsLinks;
     }
 
     for (RevCommit parent : commit.getParents()) {
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 411c9b6..3e7d0bc 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -98,29 +97,27 @@
     update.setAssignee(newAssignee.getAccountId());
     // reviewdb
     change.setAssignee(newAssignee.getAccountId());
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
     if (oldAssignee == null) {
       msg.append("added: ");
-      msg.append(newAssignee.getNameEmail());
+      msg.append(ChangeMessagesUtil.getAccountTemplate(newAssignee.getAccountId()));
     } else {
       msg.append("changed from: ");
-      msg.append(oldAssignee.getNameEmail());
+      msg.append(ChangeMessagesUtil.getAccountTemplate(oldAssignee.getAccountId()));
       msg.append(" to: ");
-      msg.append(newAssignee.getNameEmail());
+      msg.append(ChangeMessagesUtil.getAccountTemplate(newAssignee.getAccountId()));
     }
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     try {
       SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
@@ -134,6 +131,9 @@
           "Cannot send email to new assignee of change %s", change.getId());
     }
     assigneeChanged.fire(
-        change, ctx.getAccount(), oldAssignee != null ? oldAssignee.state() : null, ctx.getWhen());
+        ctx.getChangeData(change),
+        ctx.getAccount(),
+        oldAssignee != null ? oldAssignee.state() : null,
+        ctx.getWhen());
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 712e1f3..bfc4834 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -35,7 +34,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -106,7 +105,7 @@
         updated.addAll(toAdd);
         updated.removeAll(toRemove);
         update.setHashtags(updated);
-        addMessage(ctx, update);
+        addMessage(ctx);
       }
 
       updatedHashtags = ImmutableSortedSet.copyOf(updated);
@@ -116,13 +115,11 @@
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
   }
 
   private void appendHashtagMessage(StringBuilder b, String action, Set<String> hashtags) {
@@ -144,10 +141,15 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (updated() && fireEvent) {
       hashtagsEdited.fire(
-          change, ctx.getAccount(), updatedHashtags, toAdd, toRemove, ctx.getWhen());
+          ctx.getChangeData(change),
+          ctx.getAccount(),
+          updatedHashtags,
+          toAdd,
+          toRemove,
+          ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index 382a4f6..1274a5ed 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -17,7 +17,6 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -83,18 +82,18 @@
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
     update.setPrivate(isPrivate);
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!isNoOp) {
-      privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+      privateStateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     Change c = ctx.getChange();
     StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
 
@@ -104,13 +103,9 @@
       buf.append(m);
     }
 
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(
-            ctx,
-            buf.toString(),
-            c.isPrivate()
-                ? ChangeMessagesUtil.TAG_SET_PRIVATE
-                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(
+        ctx,
+        buf.toString(),
+        c.isPrivate() ? ChangeMessagesUtil.TAG_SET_PRIVATE : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
index c4a49b0..ee35d1d 100644
--- a/java/com/google/gerrit/server/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -17,14 +17,13 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -74,16 +73,14 @@
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (change != null) {
-      topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
+      topicEdited.fire(ctx.getChangeData(change), ctx.getAccount(), oldTopicName, ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index f0ebb80..1409170 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -65,7 +64,7 @@
   private Change change;
   private ChangeNotes notes;
   private PatchSet ps;
-  private ChangeMessage cmsg;
+  private String mailMessage;
 
   @Inject
   WorkInProgressOp(
@@ -99,11 +98,11 @@
     }
     change.setLastUpdatedOn(ctx.getWhen());
     update.setWorkInProgress(workInProgress);
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     Change c = ctx.getChange();
     StringBuilder buf =
         new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
@@ -114,20 +113,18 @@
       buf.append(m);
     }
 
-    cmsg =
-        ChangeMessagesUtil.newMessage(
+    mailMessage =
+        cmUtil.setChangeMessage(
             ctx,
             buf.toString(),
             c.isWorkInProgress()
                 ? ChangeMessagesUtil.TAG_SET_WIP
                 : ChangeMessagesUtil.TAG_SET_READY);
-
-    cmUtil.addChangeMessage(update, cmsg);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+  public void postUpdate(PostUpdateContext ctx) {
+    stateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (workInProgress
         || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
@@ -147,9 +144,10 @@
             notes,
             ps,
             ctx.getIdentifiedUser(),
-            cmsg,
+            mailMessage,
+            ctx.getWhen(),
             ImmutableList.of(),
-            cmsg.getMessage(),
+            mailMessage,
             ImmutableList.of(),
             repoView)
         .sendAsync();
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 4ab97f8..59819bb 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -39,10 +39,10 @@
   public String runAs;
   public String runGC;
   public String streamEvents;
+  public String viewAccess;
   public String viewAllAccounts;
   public String viewCaches;
   public String viewConnections;
   public String viewPlugins;
   public String viewQueue;
-  public String viewAccess;
 }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 58ce098..00df1e6 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -35,6 +36,8 @@
  */
 @Singleton
 public class DownloadConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ImmutableSet<String> downloadSchemes;
   private final ImmutableSet<DownloadCommand> downloadCommands;
   private final ImmutableSet<ArchiveFormatInternal> archiveFormats;
@@ -51,7 +54,8 @@
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          throw new IllegalArgumentException("not a core download scheme: " + s);
+          logger.atWarning().log("not a core download scheme: " + s);
+          continue;
         }
         normalized.add(core);
       }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index bb851e2..4794858 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -66,11 +66,13 @@
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
@@ -391,10 +393,12 @@
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
+    DynamicSet.setOf(binder(), ResolveConflictsWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
     DynamicSet.setOf(binder(), FileHistoryWebLink.class);
     DynamicSet.setOf(binder(), DiffWebLink.class);
+    DynamicSet.setOf(binder(), EditWebLink.class);
     DynamicSet.setOf(binder(), ProjectWebLink.class);
     DynamicSet.setOf(binder(), BranchWebLink.class);
     DynamicSet.setOf(binder(), TagWebLink.class);
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index d9edf23..0390620 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -14,15 +14,23 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
-  private final String devCdn;
+  private final Optional<String> devCdn;
 
-  public GerritOptions(boolean headless, boolean slave, String devCdn) {
+  public GerritOptions(boolean headless, boolean slave) {
+    this(headless, slave, null);
+  }
+
+  public GerritOptions(boolean headless, boolean slave, @Nullable String devCdn) {
     this.headless = headless;
     this.slave = slave;
-    this.devCdn = devCdn;
+    this.devCdn = headless ? Optional.empty() : Optional.ofNullable(Strings.emptyToNull(devCdn));
   }
 
   public boolean headless() {
@@ -33,11 +41,7 @@
     return !slave;
   }
 
-  public String devCdn() {
+  public Optional<String> devCdn() {
     return devCdn;
   }
-
-  public boolean useDevCdn() {
-    return !headless && devCdn.length() > 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 8214f03..f90a72e 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -27,11 +27,13 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.extensions.webui.BranchWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -71,6 +73,7 @@
         }
 
         if (!isNullOrEmpty(type.getFile()) || !isNullOrEmpty(type.getRootTree())) {
+          DynamicSet.bind(binder(), EditWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
         }
 
@@ -81,6 +84,7 @@
         if (!isNullOrEmpty(type.getRevision())) {
           DynamicSet.bind(binder(), PatchSetWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), ParentWebLink.class).to(GitwebLinks.class);
+          DynamicSet.bind(binder(), ResolveConflictsWebLink.class).to(GitwebLinks.class);
         }
 
         if (!isNullOrEmpty(type.getProject())) {
@@ -253,11 +257,13 @@
   @Singleton
   static class GitwebLinks
       implements BranchWebLink,
+          EditWebLink,
           FileHistoryWebLink,
           FileWebLink,
           PatchSetWebLink,
           ParentWebLink,
           ProjectWebLink,
+          ResolveConflictsWebLink,
           TagWebLink {
     private final String url;
     private final GitwebType type;
@@ -327,6 +333,12 @@
     }
 
     @Override
+    public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
+      // For Gitweb treat edit links the same as file links
+      return getFileWebLink(projectName, revision, fileName);
+    }
+
+    @Override
     public WebLinkInfo getPatchSetWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
       if (revision != null) {
@@ -341,6 +353,13 @@
     }
 
     @Override
+    public WebLinkInfo getResolveConflictsWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
+      // For Gitweb treat resolve conflicts links the same as patch set links
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+    }
+
+    @Override
     public WebLinkInfo getParentWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
diff --git a/java/com/google/gerrit/server/diff/DiffInfoCreator.java b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
index c29ffc8..606e42b 100644
--- a/java/com/google/gerrit/server/diff/DiffInfoCreator.java
+++ b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
@@ -73,6 +73,8 @@
 
     ImmutableList<DiffWebLinkInfo> links = webLinksProvider.getDiffLinks();
     result.webLinks = links.isEmpty() ? null : links;
+    ImmutableList<WebLinkInfo> editLinks = webLinksProvider.getEditWebLinks();
+    result.editWebLinks = editLinks.isEmpty() ? null : editLinks;
 
     if (ps.isBinary()) {
       result.binary = true;
@@ -156,8 +158,8 @@
         FileContentUtil.resolveContentType(
             state, side.fileName(), fileInfo.mode, fileInfo.mimeType);
     result.lines = fileInfo.content.getSize();
-    ImmutableList<WebLinkInfo> links = webLinksProvider.getFileWebLinks(side.type());
-    result.webLinks = links.isEmpty() ? null : links;
+    ImmutableList<WebLinkInfo> fileLinks = webLinksProvider.getFileWebLinks(side.type());
+    result.webLinks = fileLinks.isEmpty() ? null : fileLinks;
     result.commitId = fileInfo.commitId;
     return Optional.of(result);
   }
diff --git a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
index 0f71b17..2590ebc 100644
--- a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
+++ b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
@@ -24,6 +24,9 @@
   /** Returns links associated with the diff view */
   ImmutableList<DiffWebLinkInfo> getDiffLinks();
 
-  /** Returns links associated with the diff side */
+  /** Returns edit links associated with the diff view */
+  ImmutableList<WebLinkInfo> getEditWebLinks();
+
+  /** Returns file links associated with the diff side */
   ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType);
 }
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index b648255..caf495f 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -91,6 +92,7 @@
   private final ChangeKindCache changeKindCache;
   private final Provider<InternalChangeQuery> queryProvider;
   private final IndexConfig indexConfig;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   @Inject
   EventFactory(
@@ -103,7 +105,8 @@
       ApprovalsUtil approvalsUtil,
       ChangeKindCache changeKindCache,
       Provider<InternalChangeQuery> queryProvider,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      ChangeMessagesUtil changeMessagesUtil) {
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.emails = emails;
@@ -114,6 +117,7 @@
     this.changeKindCache = changeKindCache;
     this.queryProvider = queryProvider;
     this.indexConfig = indexConfig;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   public ChangeAttribute asChangeAttribute(Change change) {
@@ -543,7 +547,7 @@
         message.getAuthor() != null
             ? asAccountAttribute(message.getAuthor())
             : asAccountAttribute(myIdent.get());
-    a.message = message.getMessage();
+    a.message = changeMessagesUtil.replaceTemplates(message.getMessage());
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index af49438..0f85578 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,6 +22,9 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
+  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
+      "GerritBackendRequestFeature__remove_revision_etag";
+
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
       ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index 2189690..e31a1b5 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -42,14 +42,14 @@
   }
 
   public void fire(
-      Change change, AccountState accountState, AccountState oldAssignee, Timestamp when) {
+      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
+              util.changeInfo(changeData),
               util.accountInfo(accountState),
               util.accountInfo(oldAssignee),
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index c7a9283..cbe7c6b 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,7 +49,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState abandoner,
       String reason,
@@ -61,8 +61,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(abandoner),
               reason,
               when,
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 1ed6209..23a4583 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -41,12 +41,12 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState deleter, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState deleter, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      Event event = new Event(util.changeInfo(changeData), util.accountInfo(deleter), when);
       listeners.runEach(l -> l.onChangeDeleted(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 06d0008..e4896df 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,15 +49,19 @@
   }
 
   public void fire(
-      Change change, PatchSet ps, AccountState merger, String newRevisionId, Timestamp when) {
+      ChangeData changeData,
+      PatchSet ps,
+      AccountState merger,
+      String newRevisionId,
+      Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(merger),
               newRevisionId,
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 1af56d0..8bd222a 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,15 +49,15 @@
   }
 
   public void fire(
-      Change change, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(restorer),
               reason,
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index d608c52..4a46eb0 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -39,12 +39,12 @@
     this.util = util;
   }
 
-  public void fire(Change change, Change revertChange, Timestamp when) {
+  public void fire(ChangeData changeData, ChangeData revertChangeData, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.changeInfo(revertChange), when);
+      Event event = new Event(util.changeInfo(changeData), util.changeInfo(revertChangeData), when);
       listeners.runEach(l -> l.onChangeReverted(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 151298c..20c54cf 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState author,
       String comment,
@@ -64,8 +64,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(author),
               comment,
               util.approvals(author, approvals, when),
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index a35140a..f0d038a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -84,8 +83,8 @@
     this.changeOptions = parseChangeListOptions(gerritConfig);
   }
 
-  public ChangeInfo changeInfo(Change change) {
-    return changeJsonFactory.create(changeOptions).format(change);
+  public ChangeInfo changeInfo(ChangeData changeData) {
+    return changeJsonFactory.create(changeOptions).format(changeData);
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 5d9c5c2..846257c 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -24,6 +23,7 @@
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -45,7 +45,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       AccountState editor,
       ImmutableSortedSet<String> hashtags,
       Set<String> added,
@@ -57,7 +57,12 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change), util.accountInfo(editor), hashtags, added, removed, when);
+              util.changeInfo(changeData),
+              util.accountInfo(editor),
+              hashtags,
+              added,
+              removed,
+              when);
       listeners.runEach(l -> l.onHashtagsEdited(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index bcc6b8e..d81068c 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -28,6 +27,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -47,15 +47,15 @@
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(account),
               when);
       listeners.runEach(l -> l.onPrivateStateChanged(event));
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 35e7828..ba73ca1 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       List<AccountState> reviewers,
       AccountState adder,
@@ -63,8 +63,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               Lists.transform(reviewers, util::accountInfo),
               util.accountInfo(adder),
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 147f980..80037bc 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       AccountState reviewer,
       AccountState remover,
@@ -66,8 +66,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(reviewer),
               util.accountInfo(remover),
               message,
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 8179e9a..4c78216 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -44,7 +44,7 @@
       new RevisionCreated() {
         @Override
         public void fire(
-            Change change,
+            ChangeData changeData,
             PatchSet patchSet,
             AccountState uploader,
             Timestamp when,
@@ -66,7 +66,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       AccountState uploader,
       Timestamp when,
@@ -77,8 +77,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(uploader),
               when,
               notify.handling());
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index e4089b1..08b47f1 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -41,13 +41,14 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, String oldTopicName, Timestamp when) {
+  public void fire(
+      ChangeData changeData, AccountState account, String oldTopicName, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
-          new Event(util.changeInfo(change), util.accountInfo(account), oldTopicName, when);
+          new Event(util.changeInfo(changeData), util.accountInfo(account), oldTopicName, when);
       listeners.runEach(l -> l.onTopicEdited(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index ef4e461..244e46c 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState reviewer,
       Map<String, Short> approvals,
@@ -66,8 +66,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
               util.approvals(remover, approvals, when),
               util.approvals(remover, oldApprovals, when),
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 06b244b..bfc068d 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -28,6 +27,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,7 +41,8 @@
   public static final WorkInProgressStateChanged DISABLED =
       new WorkInProgressStateChanged() {
         @Override
-        public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {}
+        public void fire(
+            ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {}
       };
 
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
@@ -59,15 +60,15 @@
     this.util = null;
   }
 
-  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(account),
               when);
       listeners.runEach(l -> l.onWorkInProgressStateChanged(event));
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 0bc3d5c..a7f6b48 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -143,11 +143,12 @@
     }
 
     String name = e.getExportName().substring(d + 1);
-    UiAction.Description dsc;
+    UiAction.Description dsc = null;
     try (Timer1.Context<String> ignored = uiActionLatency.start(name)) {
       dsc = ((UiAction<R>) view).getDescription(resource);
+    } catch (Exception ex) {
+      logger.atSevere().withCause(ex).log("Unable to render UIAction. Will omit from actions");
     }
-
     if (dsc == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 47cbd60..2dbafd2 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Account.Id;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -48,7 +47,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.inject.Inject;
@@ -310,8 +309,9 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
-      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
+      changeReverted.fire(
+          ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
@@ -335,14 +335,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.name(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.setChangeMessage(
+          ctx,
+          "Created a revert of this change as I" + computedChangeId.name(),
+          ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 78cb013..426f8db 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -33,7 +32,7 @@
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -169,16 +168,13 @@
       }
     }
     msgBuf.append(".");
-    ChangeMessage msg =
-        ChangeMessagesUtil.newMessage(
-            psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
-    cmUtil.addChangeMessage(update, msg);
+    cmUtil.setChangeMessage(update, msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
     update.putApproval(LabelId.legacySubmit().get(), (short) 1);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!correctBranch) {
       return;
     }
@@ -214,7 +210,8 @@
                   }
                 }));
 
-    changeMerged.fire(change, patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
+    changeMerged.fire(
+        ctx.getChangeData(change), patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
   }
 
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index d6220a2..57fcf71 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -43,6 +43,17 @@
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Builds a set of tags, and tracks which tags are reachable from which non-tag, non-special refs.
+ * An instance is constructed from a snapshot of the ref database. TagSets can be incrementally
+ * updated to newer states of the RefDatabase using the refresh method. The updateFastForward method
+ * can do partial updates based on individual refs moving forward.
+ *
+ * <p>This set is used to determine which tags should be advertised when only a subset of refs is
+ * visible to a user.
+ *
+ * <p>TagSets can be serialized for use in a persisted TagCache
+ */
 class TagSet {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final ImmutableSet<String> SKIPPABLE_REF_PREFIXES =
@@ -53,7 +64,14 @@
           RefNames.REFS_STARRED_CHANGES);
 
   private final Project.NameKey projectName;
+
+  /**
+   * refName => ref. CachedRef is a ref that has an integer identity, used for indexing into
+   * BitSets.
+   */
   private final Map<String, CachedRef> refs;
+
+  /** ObjectId-pointed-to-by-tag => Tag */
   private final ObjectIdOwnerMap<Tag> tags;
 
   TagSet(Project.NameKey projectName) {
@@ -86,13 +104,16 @@
     return tags;
   }
 
+  /** Record a fast-forward update of the given ref. This is called from multiple threads. */
   boolean updateFastForward(String refName, ObjectId oldValue, ObjectId newValue) {
+    // this looks fishy: refs is not a thread-safe data structure, but it is mutated in build() and
+    // rebuild(). TagSetHolder prohibits concurrent writes through the buildLock mutex, but it does
+    // not prohibit concurrent read/write.
     CachedRef ref = refs.get(refName);
     if (ref != null) {
       // compareAndSet works on reference equality, but this operation
       // wants to use object equality. Switch out oldValue with cur so the
       // compareAndSet will function correctly for this operation.
-      //
       ObjectId cur = ref.get();
       if (cur.equals(oldValue)) {
         return ref.compareAndSet(cur, newValue);
@@ -391,6 +412,9 @@
   }
 
   static final class Tag extends ObjectIdOwnerMap.Entry {
+
+    // a RefCache.flag => isVisible map. This reference is aliased to the
+    // bitset in TagCommit.refFlags.
     @VisibleForTesting final BitSet refFlags;
 
     Tag(AnyObjectId id, BitSet flags) {
@@ -407,11 +431,12 @@
       return MoreObjects.toStringHelper(this).addValue(name()).add("refFlags", refFlags).toString();
     }
   }
-
+  /** A ref along with its index into BitSet. */
   @VisibleForTesting
   static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
 
+    /** unique identifier for this ref within the TagSet. */
     final int flag;
 
     CachedRef(Ref ref, int flag) {
@@ -444,7 +469,9 @@
     }
   }
 
+  // TODO(hanwen): this would be better named as CommitWithReachability, as it also holds non-tags.
   private static final class TagCommit extends RevCommit {
+    /** CachedRef.flag => isVisible, indicating if this commit is reachable from the ref. */
     final BitSet refFlags;
 
     TagCommit(AnyObjectId id) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index f35b3dd..15bc603 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;
@@ -1531,6 +1531,14 @@
     @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
     boolean removePrivate;
 
+    /**
+     * The skip-validation option is defined to allow parsing it using the {@link #cmdLineParser}.
+     * However we do not allow this option for pushes to magic branches. This option is used to fail
+     * with a proper error message.
+     */
+    @Option(name = "--skip-validation", usage = "skips commit validation")
+    boolean skipValidation;
+
     @Option(
         name = "--wip",
         aliases = {"-work-in-progress"},
@@ -1675,7 +1683,7 @@
      * account IDs computed from the commit message itself.
      *
      * @param additionalRecipients recipients parsed from the commit.
-     * @return set of reviewer strings to pass to {@code ReviewerAdder}.
+     * @return set of reviewer strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCombinedReviewers(MailRecipients additionalRecipients) {
       return getCombinedReviewers(reviewer, additionalRecipients.getReviewers());
@@ -1689,7 +1697,7 @@
      * account IDs computed from the commit message itself.
      *
      * @param additionalRecipients recipients parsed from the commit.
-     * @return set of CC strings to pass to {@code ReviewerAdder}.
+     * @return set of CC strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCombinedCcs(MailRecipients additionalRecipients) {
       return getCombinedReviewers(cc, additionalRecipients.getCcOnly());
@@ -1814,6 +1822,14 @@
         ref = null; // never happens
       }
 
+      if (magicBranch.skipValidation) {
+        reject(
+            cmd,
+            String.format(
+                "\"--%s\" option is only supported for direct push", PUSH_OPTION_SKIP_VALIDATION));
+        return;
+      }
+
       if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
         reject(
             cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
@@ -3143,7 +3159,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       String refName = cmd.getRefName();
       if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
         logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName());
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index ce62d7a..cc908e4 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -30,14 +30,13 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.SubmissionId;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -52,10 +51,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 +73,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
@@ -130,7 +130,7 @@
   private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final Change change;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
@@ -154,11 +154,11 @@
   private ChangeNotes notes;
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
-  private ChangeMessage msg;
+  private String mailMessage;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
@@ -175,7 +175,7 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       Change change,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
@@ -203,7 +203,7 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.change = change;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
@@ -323,12 +323,13 @@
         update, projectState.getLabelTypes(), newPatchSet, ctx.getUser(), approvals);
 
     reviewerAdditions =
-        reviewerAdder.prepare(
+        reviewerModifier.prepare(
             ctx.getNotes(),
             ctx.getUser(),
             getReviewerInputs(magicBranch, fromFooters, ctx.getChange(), info),
             true);
-    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+    Optional<ReviewerModification> reviewerError =
+        reviewerAdditions.getFailures().stream().findFirst();
     if (reviewerError.isPresent()) {
       throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
@@ -341,8 +342,7 @@
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    msg = createChangeMessage(ctx, reviewMessage);
-    cmUtil.addChangeMessage(update, msg);
+    mailMessage = insertChangeMessage(update, ctx, reviewMessage);
 
     if (mergedByPushOp == null) {
       resetChange(ctx);
@@ -353,24 +353,24 @@
     return true;
   }
 
-  private ImmutableList<AddReviewerInput> getReviewerInputs(
+  private ImmutableList<ReviewerInput> getReviewerInputs(
       @Nullable MagicBranchInput magicBranch,
       MailRecipients fromFooters,
       Change change,
       PatchSetInfo psInfo) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
-    Stream<AddReviewerInput> inputs =
+    Stream<ReviewerInput> inputs =
         Streams.concat(
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getAuthor().getAccount(),
                     NotifyHandling.NONE,
                     newPatchSet.uploader())),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getCommitter().getAccount(),
@@ -381,28 +381,27 @@
           Streams.concat(
               inputs,
               magicBranch.getCombinedReviewers(fromFooters).stream()
-                  .map(r -> newAddReviewerInput(r, ReviewerState.REVIEWER)),
+                  .map(r -> newReviewerInput(r, ReviewerState.REVIEWER)),
               magicBranch.getCombinedCcs(fromFooters).stream()
-                  .map(r -> newAddReviewerInput(r, ReviewerState.CC)));
+                  .map(r -> newReviewerInput(r, ReviewerState.CC)));
     }
     return inputs.collect(toImmutableList());
   }
 
-  private static InternalAddReviewerInput newAddReviewerInput(
-      String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new patch set email.
-    InternalAddReviewerInput input =
-        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+    InternalReviewerInput input =
+        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
 
     return input;
   }
 
-  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage)
       throws IOException {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
@@ -421,12 +420,8 @@
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
     }
-    return ChangeMessagesUtil.newMessage(
-        patchSetId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        message.toString(),
-        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+    return cmUtil.setChangeMessage(
+        update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
   private String changeKindMessage(ChangeKind changeKind) {
@@ -493,7 +488,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
       // TODO(dborowitz): Merge email templates so we only have to send one.
@@ -506,7 +501,8 @@
       }
     }
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
+    revisionCreated.fire(
+        ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
       fireApprovalsEvent(ctx);
     } catch (Exception e) {
@@ -531,7 +527,7 @@
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
         emailSender.setFrom(ctx.getAccount().account().id());
         emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
         emailSender.addReviewers(
             Streams.concat(
@@ -560,7 +556,7 @@
     }
   }
 
-  private void fireApprovalsEvent(Context ctx) {
+  private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
     }
@@ -588,7 +584,7 @@
       }
     }
     commentAdded.fire(
-        notes.getChange(),
+        ctx.getChangeData(notes),
         newPatchSet,
         ctx.getAccount(),
         null,
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index cbaa121..6b145ca 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -51,6 +51,7 @@
 import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -104,7 +105,8 @@
             new PluginMergeValidationListener(mergeValidationListeners),
             projectConfigValidatorFactory.create(),
             accountValidatorFactory.create(),
-            groupValidatorFactory.create());
+            groupValidatorFactory.create(),
+            new DestBranchRefValidator());
 
     for (MergeValidationListener validator : validators) {
       validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
@@ -198,7 +200,7 @@
                   throw new MergeValidationException(SET_BY_ADMIN, e);
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
-                  throw new MergeValidationException("validation unavailable");
+                  throw new MergeValidationException("validation unavailable", e);
                 }
               } else {
                 try {
@@ -210,7 +212,7 @@
                   throw new MergeValidationException(SET_BY_OWNER, e);
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check WRITE_CONFIG");
-                  throw new MergeValidationException("validation unavailable");
+                  throw new MergeValidationException("validation unavailable", e);
                 }
               }
               if (allUsersName.equals(destProject.getNameKey())
@@ -317,7 +319,7 @@
         }
       } catch (StorageException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
-        throw new MergeValidationException("account validation unavailable");
+        throw new MergeValidationException("account validation unavailable", e);
       }
 
       try {
@@ -329,7 +331,7 @@
         }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
-        throw new MergeValidationException("account validation unavailable");
+        throw new MergeValidationException("account validation unavailable", e);
       }
     }
   }
@@ -366,4 +368,34 @@
       throw new MergeValidationException("group update not allowed");
     }
   }
+
+  /**
+   * Validator to ensure that destBranch is not a symbolic reference (an attempt to merge into a
+   * symbolic ref branch leads to LOCK_FAILURE exception).
+   */
+  private static class DestBranchRefValidator implements MergeValidationListener {
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewRevWalk revWalk,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        BranchNameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      try {
+        Ref ref = repo.exactRef(destBranch.branch());
+        // Usually the target branch exists, but there is an exception for some branches (see
+        // {@link com.google.gerrit.server.git.receive.ReceiveCommits} for details).
+        // Such non-existing branches should be ignored.
+        if (ref != null && ref.isSymbolic()) {
+          throw new MergeValidationException("the target branch is a symbolic ref");
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Cannot validate destination branch");
+        throw new MergeValidationException("symref validation unavailable", e);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index b2d1849b..c187186 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -95,19 +95,18 @@
   private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
 
   /**
-   * Creates a {@code GroupConfig} for a new group from the {@code InternalGroupCreation} blueprint.
-   * Further, optional properties can be specified by setting an {@code InternalGroupUpdate} via
-   * {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)} on the returned {@code
-   * GroupConfig}.
+   * Creates a {@link GroupConfig} for a new group from the {@link InternalGroupCreation} blueprint.
+   * Further, optional properties can be specified by setting a {@link GroupDelta} via {@link
+   * #setGroupDelta(GroupDelta, AuditLogFormatter)} on the returned {@link GroupConfig}.
    *
-   * <p><strong>Note:</strong> The returned {@code GroupConfig} has to be committed via {@link
+   * <p><strong>Note:</strong> The returned {@link GroupConfig} has to be committed via {@link
    * #commit(MetaDataUpdate)} in order to create the group for real.
    *
    * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
-   * @param groupCreation an {@code InternalGroupCreation} specifying all properties which are
+   * @param groupCreation an {@link InternalGroupCreation} specifying all properties which are
    *     required for a new group
-   * @return a {@code GroupConfig} for a group creation
+   * @return a {@link GroupConfig} for a group creation
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if a group with the same UUID already exists but can't be read
    *     due to an invalid format
@@ -123,7 +122,7 @@
   }
 
   /**
-   * Creates a {@code GroupConfig} for an existing group.
+   * Creates a {@link GroupConfig} for an existing group.
    *
    * <p>The group is automatically loaded within this method and can be accessed via {@link
    * #getLoadedGroup()}.
@@ -131,14 +130,14 @@
    * <p>It's safe to call this method for non-existing groups. In that case, {@link
    * #getLoadedGroup()} won't return any group. Thus, the existence of a group can be easily tested.
    *
-   * <p>The group represented by the returned {@code GroupConfig} can be updated by setting an
-   * {@code InternalGroupUpdate} via {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)}
-   * and committing the {@code GroupConfig} via {@link #commit(MetaDataUpdate)}.
+   * <p>The group represented by the returned {@link GroupConfig} can be updated by setting an
+   * {@link GroupDelta} via {@link #setGroupDelta(GroupDelta, AuditLogFormatter)} and committing the
+   * {@link GroupConfig} via {@link #commit(MetaDataUpdate)}.
    *
    * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
-   * @return a {@code GroupConfig} for the group with the specified UUID
+   * @return a {@link GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
@@ -169,7 +168,7 @@
   }
 
   /**
-   * Creates a {@code GroupConfig} for an existing group at a specific revision of the repository.
+   * Creates a {@link GroupConfig} for an existing group at a specific revision of the repository.
    *
    * <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
    * AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
@@ -180,7 +179,7 @@
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @param commitId the revision of the repository at which the group should be loaded
-   * @return a {@code GroupConfig} for the group with the specified UUID
+   * @return a {@link GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
@@ -200,7 +199,7 @@
 
   private Optional<InternalGroup> loadedGroup = Optional.empty();
   private Optional<InternalGroupCreation> groupCreation = Optional.empty();
-  private Optional<InternalGroupUpdate> groupUpdate = Optional.empty();
+  private Optional<GroupDelta> groupDelta = Optional.empty();
   private AuditLogFormatter auditLogFormatter = AuditLogFormatter.createPartiallyWorkingFallBack();
   private boolean isLoaded = false;
   private boolean allowSaveEmptyName;
@@ -213,16 +212,16 @@
   /**
    * Returns the group loaded from NoteDb.
    *
-   * <p>If not any NoteDb commits exist for the group represented by this {@code GroupConfig}, no
+   * <p>If not any NoteDb commits exist for the group represented by this {@link GroupConfig}, no
    * group is returned.
    *
-   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@code GroupConfig}, this method
+   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@link GroupConfig}, this method
    * returns a group which is in line with the latest NoteDb commit for this group. So, after
-   * creating a {@code GroupConfig} for a new group and committing it, this method can be used to
+   * creating a {@link GroupConfig} for a new group and committing it, this method can be used to
    * retrieve a representation of the created group. The same holds for the representation of an
    * updated group.
    *
-   * @return the loaded group, or an empty {@code Optional} if the group doesn't exist
+   * @return the loaded group, or an empty {@link Optional} if the group doesn't exist
    */
   public Optional<InternalGroup> getLoadedGroup() {
     checkLoaded();
@@ -232,20 +231,19 @@
   /**
    * Specifies how the current group should be updated.
    *
-   * <p>If the group is newly created, the {@code InternalGroupUpdate} can be used to specify
-   * optional properties.
+   * <p>If the group is newly created, the {@link GroupDelta} can be used to specify optional
+   * properties.
    *
    * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
    * instructions for the update. To apply the update for real and write the result back to NoteDb,
-   * call {@link #commit(MetaDataUpdate)} on this {@code GroupConfig}.
+   * call {@link #commit(MetaDataUpdate)} on this {@link GroupConfig}.
    *
-   * @param groupUpdate an {@code InternalGroupUpdate} outlining the modifications which should be
-   *     applied
-   * @param auditLogFormatter an {@code AuditLogFormatter} for formatting the commit message in a
+   * @param groupDelta a {@link GroupDelta} with the modifications to be applied
+   * @param auditLogFormatter an {@link AuditLogFormatter} for formatting the commit message in a
    *     parsable way
    */
-  public void setGroupUpdate(InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter) {
-    this.groupUpdate = Optional.of(groupUpdate);
+  public void setGroupDelta(GroupDelta groupDelta, AuditLogFormatter auditLogFormatter) {
+    this.groupDelta = Optional.of(groupDelta);
     this.auditLogFormatter = auditLogFormatter;
   }
 
@@ -304,7 +302,7 @@
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
-    if (!groupCreation.isPresent() && !groupUpdate.isPresent()) {
+    if (!groupCreation.isPresent() && !groupDelta.isPresent()) {
       // Group was neither created nor changed. -> A new commit isn't necessary.
       return false;
     }
@@ -318,7 +316,7 @@
     // for new groups, we explicitly need to truncate the timestamp here.
     Timestamp commitTimestamp =
         TimeUtil.truncateToSecond(
-            groupUpdate.flatMap(InternalGroupUpdate::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::nowTs));
     commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
     commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
@@ -329,7 +327,7 @@
 
     loadedGroup = Optional.of(updatedGroup);
     groupCreation = Optional.empty();
-    groupUpdate = Optional.empty();
+    groupDelta = Optional.empty();
 
     return true;
   }
@@ -339,8 +337,8 @@
   }
 
   private Optional<String> getNewName() {
-    if (groupUpdate.isPresent()) {
-      return groupUpdate.get().getName().map(n -> Strings.nullToEmpty(n.get()));
+    if (groupDelta.isPresent()) {
+      return groupDelta.get().getName().map(n -> Strings.nullToEmpty(n.get()));
     }
     if (groupCreation.isPresent()) {
       return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get()));
@@ -377,11 +375,10 @@
         internalGroupCreation ->
             Arrays.stream(GroupConfigEntry.values())
                 .forEach(configEntry -> configEntry.initNewConfig(config, internalGroupCreation)));
-    groupUpdate.ifPresent(
-        internalGroupUpdate ->
+    groupDelta.ifPresent(
+        delta ->
             Arrays.stream(GroupConfigEntry.values())
-                .forEach(
-                    configEntry -> configEntry.updateConfigValue(config, internalGroupUpdate)));
+                .forEach(configEntry -> configEntry.updateConfigValue(config, delta)));
     saveConfig(GROUP_CONFIG_FILE, config);
     return config;
   }
@@ -389,8 +386,8 @@
   private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers)
       throws IOException {
     Optional<ImmutableSet<Account.Id>> updatedMembers =
-        groupUpdate
-            .map(InternalGroupUpdate::getMemberModification)
+        groupDelta
+            .map(GroupDelta::getMemberModification)
             .map(memberModification -> memberModification.apply(originalMembers))
             .map(ImmutableSet::copyOf)
             .filter(members -> !originalMembers.equals(members));
@@ -403,8 +400,8 @@
   private Optional<ImmutableSet<AccountGroup.UUID>> updateSubgroups(
       ImmutableSet<AccountGroup.UUID> originalSubgroups) throws IOException {
     Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups =
-        groupUpdate
-            .map(InternalGroupUpdate::getSubgroupModification)
+        groupDelta
+            .map(GroupDelta::getSubgroupModification)
             .map(subgroupModification -> subgroupModification.apply(originalSubgroups))
             .map(ImmutableSet::copyOf)
             .filter(subgroups -> !originalSubgroups.equals(subgroups));
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index be56344..687e3fb 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -26,8 +26,8 @@
  * <p>Each property knows how to read and write its value from/to a JGit {@link Config} file.
  *
  * <p><strong>Warning:</strong> This class is a low-level API for properties of groups in NoteDb. It
- * may only be used by {@link GroupConfig}. Other classes should use {@link InternalGroupUpdate} to
- * modify the properties of a group.
+ * may only be used by {@link GroupConfig}. Other classes should use {@link GroupDelta} to modify
+ * the properties of a group.
  */
 enum GroupConfigEntry {
   /**
@@ -59,7 +59,7 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
       // Updating the ID is not supported.
     }
   },
@@ -87,8 +87,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getName()
           .ifPresent(name -> config.setString(SECTION_NAME, null, super.keyName, name.get()));
     }
@@ -112,8 +112,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getDescription()
           .ifPresent(
               description ->
@@ -144,8 +144,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getOwnerGroupUUID()
           .ifPresent(
               ownerGroupUuid ->
@@ -171,8 +171,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getVisibleToAll()
           .ifPresent(
               visibleToAll -> config.setBoolean(SECTION_NAME, null, super.keyName, visibleToAll));
@@ -217,13 +217,13 @@
 
   /**
    * Updates the corresponding property of this {@code GroupConfigEntry} in the given {@code Config}
-   * if the {@code InternalGroupUpdate} mentions a modification.
+   * if the {@link GroupDelta} mentions a modification.
    *
-   * <p>This call is a no-op if the {@code InternalGroupUpdate} doesn't contain a modification for
-   * the property.
+   * <p>This call is a no-op if the {@link GroupDelta} doesn't contain a modification for the
+   * property.
    *
    * @param config a {@code Config} for which the property should be updated
-   * @param groupUpdate an {@code InternalGroupUpdate} detailing the modifications on a group
+   * @param groupDelta a {@link GroupDelta} detailing the modifications on a group
    */
-  abstract void updateConfigValue(Config config, InternalGroupUpdate groupUpdate);
+  abstract void updateConfigValue(Config config, GroupDelta groupDelta);
 }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/GroupDelta.java
similarity index 81%
rename from java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
rename to java/com/google/gerrit/server/group/db/GroupDelta.java
index 5c7408c..4ef2450 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupDelta.java
@@ -23,14 +23,13 @@
 import java.util.Set;
 
 /**
- * Definition of an update to a group.
+ * Data holder for updates to be applied to a group.
  *
- * <p>An {@code InternalGroupUpdate} only specifies the modifications which should be applied to a
- * group. Each of the modifications and hence each call on {@link InternalGroupUpdate.Builder} is
- * optional.
+ * <p>A {@link GroupDelta} specifies the modifications to be applied to a group. Only fields set via
+ * {@link GroupDelta.Builder} will be updated.
  */
 @AutoValue
-public abstract class InternalGroupUpdate {
+public abstract class GroupDelta {
 
   /** Representation of a member modification as defined by {@link #apply(ImmutableSet)}. */
   @FunctionalInterface
@@ -99,10 +98,10 @@
    * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
    * specified, the current {@code Timestamp} when creating the commit will be used.
    *
-   * <p>If this {@code InternalGroupUpdate} is passed next to an {@link InternalGroupCreation}
-   * during a group creation, this {@code Timestamp} is used for the NoteDb commits of the new
-   * group. Hence, the {@link com.google.gerrit.entities.InternalGroup#getCreatedOn()
-   * InternalGroup#getCreatedOn()} field will match this {@code Timestamp}.
+   * <p>If this {@link GroupDelta} is passed next to an {@link InternalGroupCreation} during a group
+   * creation, this {@code Timestamp} is used for the NoteDb commits of the new group. Hence, the
+   * {@link com.google.gerrit.entities.InternalGroup#getCreatedOn() InternalGroup#getCreatedOn()}
+   * field will match this {@code Timestamp}.
    *
    * <p><strong>Note: </strong>{@code Timestamp}s of NoteDb commits for groups are used for events
    * in the audit log. For this reason, specifying this field will have an effect on the resulting
@@ -113,12 +112,12 @@
   public abstract Builder toBuilder();
 
   public static Builder builder() {
-    return new AutoValue_InternalGroupUpdate.Builder()
+    return new AutoValue_GroupDelta.Builder()
         .setMemberModification(in -> in)
         .setSubgroupModification(in -> in);
   }
 
-  /** A builder for an {@link InternalGroupUpdate}. */
+  /** A builder for a {@link GroupDelta}. */
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -139,11 +138,11 @@
 
     /**
      * Returns the currently defined {@link MemberModification} for the prospective {@link
-     * InternalGroupUpdate}.
+     * GroupDelta}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setMemberModification(InternalGroupUpdate.MemberModification)} in order to combine multiple
-     * member additions, deletions, or other modifications into one update.
+     * #setMemberModification(GroupDelta.MemberModification)} in order to combine multiple member
+     * additions, deletions, or other modifications into one update.
      */
     public abstract MemberModification getMemberModification();
 
@@ -152,17 +151,17 @@
 
     /**
      * Returns the currently defined {@link SubgroupModification} for the prospective {@link
-     * InternalGroupUpdate}.
+     * GroupDelta}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setSubgroupModification(InternalGroupUpdate.SubgroupModification)} in order to combine
-     * multiple subgroup additions, deletions, or other modifications into one update.
+     * #setSubgroupModification(GroupDelta.SubgroupModification)} in order to combine multiple
+     * subgroup additions, deletions, or other modifications into one update.
      */
     public abstract SubgroupModification getSubgroupModification();
 
     /** @see #getUpdatedOn() */
     public abstract Builder setUpdatedOn(Timestamp timestamp);
 
-    public abstract InternalGroupUpdate build();
+    public abstract GroupDelta build();
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 02d55eb..9aa5cfd 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -67,19 +67,19 @@
  * <p>All calls which write group related details to the database are gathered here. Other classes
  * should always use this class instead of accessing the database directly. There are a few
  * exceptions though: schema classes, wrapper classes, and classes executed during init. The latter
- * ones should use {@code GroupsOnInit} instead.
+ * ones should use {@link com.google.gerrit.pgm.init.GroupsOnInit} instead.
  *
  * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
  */
 public class GroupsUpdate {
   public interface Factory {
     /**
-     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
+     * Creates a {@link GroupsUpdate} which uses the identity of the specified user to mark database
      * modifications executed by it. For NoteDb, this identity is used as author and committer for
      * all related commits.
      *
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
-     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@code GroupsUpdate}
+     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@link GroupsUpdate}
      * instead.
      *
      * @param currentUser the user to which modifications should be attributed
@@ -87,12 +87,12 @@
     GroupsUpdate create(IdentifiedUser currentUser);
 
     /**
-     * Creates a {@code GroupsUpdate} which uses the server identity to mark database modifications
+     * Creates a {@link GroupsUpdate} which uses the server identity to mark database modifications
      * executed by it. For NoteDb, this identity is used as author and committer for all related
      * commits.
      *
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
-     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@code
+     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@link
      * GroupsUpdate} instead.
      */
     GroupsUpdate createWithServerIdent();
@@ -115,6 +115,7 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -149,6 +150,7 @@
   }
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -183,6 +185,7 @@
         Optional.of(currentUser));
   }
 
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -251,25 +254,24 @@
   /**
    * Creates the specified group for the specified members (accounts).
    *
-   * @param groupCreation an {@code InternalGroupCreation} which specifies all mandatory properties
+   * @param groupCreation an {@link InternalGroupCreation} which specifies all mandatory properties
    *     of the group
-   * @param groupUpdate an {@code InternalGroupUpdate} which specifies optional properties of the
-   *     group. If this {@code InternalGroupUpdate} updates a property which was already specified
-   *     by the {@code InternalGroupCreation}, the value of this {@code InternalGroupUpdate} wins.
+   * @param groupDelta a {@link GroupDelta} which specifies optional properties of the group. If
+   *     this {@link GroupDelta} updates a property which was already specified by the {@link
+   *     InternalGroupCreation}, the value of this {@link GroupDelta} wins.
    * @throws DuplicateKeyException if a group with the chosen name already exists
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
-   * @return the created {@code InternalGroup}
+   * @return the created {@link InternalGroup}
    */
-  public InternalGroup createGroup(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+  public InternalGroup createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, ConfigInvalidException {
-    try (TraceTimer timer =
+    try (TraceTimer ignored =
         TraceContext.newTimer(
             "Creating group",
             Metadata.builder()
-                .groupName(groupUpdate.getName().orElseGet(groupCreation::getNameKey).get())
+                .groupName(groupDelta.getName().orElseGet(groupCreation::getNameKey).get())
                 .build())) {
-      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
+      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupDelta);
       evictCachesOnGroupCreation(createdGroup);
       dispatchAuditEventsOnGroupCreation(createdGroup);
       return createdGroup;
@@ -280,24 +282,23 @@
    * Updates the specified group.
    *
    * @param groupUuid the UUID of the group to update
-   * @param groupUpdate an {@code InternalGroupUpdate} which indicates the desired updates on the
-   *     group
+   * @param groupDelta a {@link GroupDelta} which indicates the desired updates on the group
    * @throws DuplicateKeyException if the new name of the group is used by another group
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
    * @throws NoSuchGroupException if the specified group doesn't exist
    */
-  public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  public void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
-    try (TraceTimer timer =
+    try (TraceTimer ignored =
         TraceContext.newTimer(
             "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
-      Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
+      Optional<Timestamp> updatedOn = groupDelta.getUpdatedOn();
       if (!updatedOn.isPresent()) {
         updatedOn = Optional.of(TimeUtil.nowTs());
-        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
+        groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
       }
 
-      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupUpdate);
+      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupDelta);
       updateNameInProjectConfigsIfNecessary(result);
       evictCachesOnGroupUpdate(result);
       dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
@@ -305,11 +306,11 @@
   }
 
   private InternalGroup createGroupInNoteDbWithRetry(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try {
       return retryHelper
-          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupUpdate))
+          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -322,17 +323,17 @@
 
   @VisibleForTesting
   public InternalGroup createGroupInNoteDb(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+      AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
       GroupNameNotes groupNameNotes =
           GroupNameNotes.forNewGroup(
               allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
       GroupConfig groupConfig =
           GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
 
@@ -344,11 +345,11 @@
   }
 
   private UpdateResult updateGroupInNoteDbWithRetry(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try {
       return retryHelper
-          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupUpdate))
+          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupDelta))
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -361,21 +362,20 @@
   }
 
   @VisibleForTesting
-  public UpdateResult updateGroupInNoteDb(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
-      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
       if (!groupConfig.getLoadedGroup().isPresent()) {
         throw new NoSuchGroupException(groupUuid);
       }
 
       InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
       GroupNameNotes groupNameNotes = null;
-      if (groupUpdate.getName().isPresent()) {
+      if (groupDelta.getName().isPresent()) {
         AccountGroup.NameKey oldName = originalGroup.getNameKey();
-        AccountGroup.NameKey newName = groupUpdate.getName().get();
+        AccountGroup.NameKey newName = groupDelta.getName().get();
         groupNameNotes =
             GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
       }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
index 8988547..f4bf6e6 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
@@ -20,7 +20,7 @@
 /**
  * Definition of all properties necessary for a group creation.
  *
- * <p>An instance of {@code InternalGroupCreation} is a blueprint for a group which should be
+ * <p>An instance of {@link InternalGroupCreation} is a blueprint for a group which should be
  * created.
  */
 @AutoValue
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ce748d1..7e84f1d 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")
@@ -674,6 +679,10 @@
               cd ->
                   Stream.concat(
                           cd.publishedComments().stream().map(c -> c.message),
+                          // Some endpoint allow passing user message in input, and we still want to
+                          // search by that. Index on message template with placeholders for user
+                          // data, so we don't
+                          // persist user identifiable information data in index.
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e0f6bec..f7f0f33 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -46,6 +46,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -79,8 +80,7 @@
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
 
-  private final Set<IndexTask> queuedIndexTasks =
-      Collections.newSetFromMap(new ConcurrentHashMap<>());
+  private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
       Collections.newSetFromMap(new ConcurrentHashMap<>());
 
@@ -137,16 +137,44 @@
   /**
    * Start indexing a change.
    *
-   * @param id change to index.
+   * @param changeId change to index.
    * @return future for the indexing task.
    */
-  public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
-    IndexTask task = new IndexTask(project, id);
-    if (queuedIndexTasks.add(task)) {
-      fireChangeScheduledForIndexingEvent(project.get(), id.get());
-      return submit(task);
-    }
-    return Futures.immediateFuture(null);
+  public ListenableFuture<ChangeData> indexAsync(Project.NameKey project, Change.Id changeId) {
+    // If the change was already scheduled for indexing, we do not need to schedule it again. Change
+    // updates that happened after the change was scheduled for indexing will automatically be taken
+    // into account when the index task is executed (as it reads the current change state).
+    // To skip duplicate index requests, queuedIndexTasks keeps track of the scheduled index tasks.
+    // Here we check if the change has already been scheduled for indexing, and only if not we
+    // create a new index task for the change.
+    // By using computeIfAbsent we ensure that the lookup and the insertion of a new task happens
+    // atomically. Some attempted update operations on this map by other threads may be blocked
+    // while the computation is in progress (but not all as ConcurrentHashMap doesn't lock the
+    // entire table on write, but only segments of the table).
+    IndexTask task =
+        queuedIndexTasks.computeIfAbsent(
+            changeId,
+            id -> {
+              fireChangeScheduledForIndexingEvent(project.get(), id.get());
+              return new IndexTask(project, id);
+            });
+    // Submitting the task to the executor must not happen from within the computeIfAbsent callback,
+    // as this could result in the task being executed before the computeIfAbsent method has
+    // finished (e.g. if a direct executor is used, but also if starting the task asynchronously is
+    // faster than finishing the computeIfAbsent method). This could lead to failures and unexpected
+    // behavior:
+    // * The first thing that IndexTask does is to remove itself from queuedIndexTasks.
+    //   This is done so that index requests which are received while an index task for the same
+    //   change is in progress, are not dropped but added to the queue. This is important since
+    //   the change state that is written to the index is read at the beginning of the index task
+    //   and change updates that happen after this read will not be considered when updating the
+    //   index.
+    // * Trying to remove the IndexTask from queuedIndexTasks at the beginning of the task doesn't
+    //   work if the computeIfAbsent method hasn't finished yet. Either the queuedIndexTasks doesn't
+    //   contain the new entry yet and the removal has no effect as it is done before the entry is
+    //   added to the map, or the removal fails with {@link IllegalStateException} as recursive
+    //   updates from within the computeIfAbsent callback are not allowed.
+    return task.submitIfNeeded();
   }
 
   /**
@@ -155,8 +183,9 @@
    * @param ids changes to index.
    * @return future for completing indexing of all changes.
    */
-  public ListenableFuture<?> indexAsync(Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
+  public ListenableFuture<List<ChangeData>> indexAsync(
+      Project.NameKey project, Collection<Change.Id> ids) {
+    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       futures.add(indexAsync(project, id));
     }
@@ -259,9 +288,9 @@
    * Start deleting a change.
    *
    * @param id change to delete.
-   * @return future for the deleting task.
+   * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<?> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
     return submit(new DeleteTask(id));
   }
@@ -359,17 +388,46 @@
     }
   }
 
-  private class IndexTask extends AbstractIndexTask<Void> {
+  private class IndexTask extends AbstractIndexTask<ChangeData> {
+    ListenableFuture<ChangeData> future;
+
     private IndexTask(Project.NameKey project, Change.Id id) {
       super(project, id);
     }
 
+    /**
+     * Submits this task to be executed, if it wasn't submitted yet.
+     *
+     * <p>Submits this task to the executor if it hasn't been submitted yet. The future is cached so
+     * that it can be returned if this method is called again.
+     *
+     * <p>This method must be synchronized so that concurrent calls do not submit this task to the
+     * executor multiple times.
+     *
+     * @return future from which the result of the index task (the {@link ChangeData} instance) can
+     *     be retrieved.
+     */
+    private synchronized ListenableFuture<ChangeData> submitIfNeeded() {
+      if (future == null) {
+        future = submit(this);
+      }
+      return future;
+    }
+
     @Override
-    public Void callImpl() throws Exception {
+    public ChangeData callImpl() throws Exception {
+      // Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
+      // that index requests which are received for the same change while this index task is in
+      // progress, are not dropped but added to the queue. This is important since change updates
+      // that happen after reading the change notes below will not be considered when updating the
+      // index.
       remove();
+
       try {
         ChangeNotes changeNotes = notesFactory.createChecked(project, id);
-        doIndex(changeDataFactory.create(changeNotes));
+        ChangeData changeData = changeDataFactory.create(changeNotes);
+        doIndex(changeData);
+        return changeData;
       } catch (NoSuchChangeException e) {
         doDelete(id);
       }
@@ -397,12 +455,12 @@
 
     @Override
     protected void remove() {
-      queuedIndexTasks.remove(this);
+      queuedIndexTasks.remove(id);
     }
   }
 
   // Not AbstractIndexTask as it doesn't need a request context.
-  private class DeleteTask implements Callable<Void> {
+  private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
 
     private DeleteTask(Change.Id id) {
@@ -410,7 +468,7 @@
     }
 
     @Override
-    public Void call() {
+    public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
       // Don't bother setting a RequestContext to provide the DB.
       // Implementations should not need to access the DB in order to delete a
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 969b071..ffccb51 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -135,9 +135,14 @@
       new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
 
   /** Added new field {@link ChangeField#MERGED_ON} */
+  @Deprecated
   static final Schema<ChangeData> V61 =
       new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
 
+  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
+  static final Schema<ChangeData> V62 =
+      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 71ee01f..dc9af2b 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -66,6 +66,9 @@
   /** The cause of an error. */
   public abstract Optional<String> cause();
 
+  /** Side where the comment is written: <= 0 for parent, 1 for revision. */
+  public abstract Optional<Integer> commentSide();
+
   /** The SHA1 of a commit. */
   public abstract Optional<String> commit();
 
@@ -288,6 +291,8 @@
 
     public abstract Builder cause(@Nullable String cause);
 
+    public abstract Builder commentSide(int side);
+
     public abstract Builder commit(@Nullable String commit);
 
     public abstract Builder eventType(@Nullable String eventType);
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index ff166b1..c659b5f 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.mail.send.AbandonedSender;
 import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
@@ -26,6 +25,7 @@
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.ModifyReviewerSender;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
@@ -38,7 +38,7 @@
   protected void configure() {
     factory(AbandonedSender.Factory.class);
     factory(AddKeySender.Factory.class);
-    factory(AddReviewerSender.Factory.class);
+    factory(ModifyReviewerSender.Factory.class);
     factory(CommentSender.Factory.class);
     factory(CreateChangeSender.Factory.class);
     factory(DeleteKeySender.Factory.class);
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index df38118..3a35d80 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -23,7 +23,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -65,7 +64,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -233,7 +232,14 @@
       throws UpdateException, RestApiException {
     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
-          queryProvider.get().byLegacyChangeId(Change.id(metadata.changeNumber));
+          queryProvider
+              .get()
+              .enforceVisibility(true)
+              .byLegacyChangeId(Change.id(metadata.changeNumber));
+      if (changeDataList.isEmpty()) {
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.CHANGE_NOT_FOUND);
+        return;
+      }
       if (changeDataList.size() != 1) {
         logger.atSevere().log(
             "Message %s references unique change %s,"
@@ -313,7 +319,7 @@
     private final PatchSet.Id psId;
     private final List<MailComment> parsedComments;
     private final String tag;
-    private ChangeMessage changeMessage;
+    private String mailMessage;
     private List<HumanComment> comments;
     private PatchSet patchSet;
     private ChangeNotes notes;
@@ -332,9 +338,8 @@
         throw new StorageException("patch set not found: " + psId);
       }
 
-      changeMessage = generateChangeMessage(ctx);
-      changeMessagesUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
-
+      mailMessage =
+          changeMessagesUtil.setChangeMessage(ctx.getUpdate(psId), generateChangeMessage(), tag);
       comments = new ArrayList<>();
       for (MailComment c : parsedComments) {
         if (c.getType() == MailComment.CommentType.CHANGE_MESSAGE) {
@@ -352,7 +357,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
       String patchSetComment = null;
       if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         patchSetComment = parsedComments.get(0).getMessage();
@@ -364,7 +369,8 @@
               notes,
               patchSet,
               ctx.getUser().asIdentifiedUser(),
-              changeMessage,
+              mailMessage,
+              ctx.getWhen(),
               comments,
               patchSetComment,
               ImmutableList.of(),
@@ -379,16 +385,16 @@
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
-          notes.getChange(),
+          ctx.getChangeData(notes),
           patchSet,
           ctx.getAccount(),
-          changeMessage.getMessage(),
+          mailMessage,
           approvals,
           approvals,
           ctx.getWhen());
     }
 
-    private ChangeMessage generateChangeMessage(ChangeContext ctx) {
+    private String generateChangeMessage() {
       String changeMsg = "Patch Set " + psId.get() + ":";
       if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         // Add a blank line after Patch Set to follow the default format
@@ -399,7 +405,7 @@
       } else {
         changeMsg += "\n\n" + numComments(parsedComments.size());
       }
-      return ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+      return changeMsg;
     }
 
     private PatchSet targetPatchSetForComment(
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 709bf61..acdeb5a 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -33,7 +33,8 @@
     INACTIVE_ACCOUNT,
     UNKNOWN_ACCOUNT,
     INTERNAL_EXCEPTION,
-    COMMENT_REJECTED
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
   }
 
   public interface Factory {
diff --git a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
similarity index 87%
rename from java/com/google/gerrit/server/mail/send/AddReviewerSender.java
rename to java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index 96d9483..dcf3b6c 100644
--- a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -21,13 +21,13 @@
 import com.google.inject.assistedinject.Assisted;
 
 /** Asks a user to review a change. */
-public class AddReviewerSender extends NewChangeSender {
+public class ModifyReviewerSender extends NewChangeSender {
   public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id changeId);
+    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
-  public AddReviewerSender(
+  public ModifyReviewerSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
     super(args, newChangeData(args, project, changeId));
   }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index ee9a328..001de52 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -31,6 +31,8 @@
   private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final Set<Address> extraCCByEmail = new HashSet<>();
+  private final Set<Account.Id> removedReviewers = new HashSet<>();
+  private final Set<Address> removedByEmailReviewers = new HashSet<>();
 
   protected NewChangeSender(EmailArguments args, ChangeData changeData) {
     super(args, "newchange", changeData);
@@ -52,10 +54,17 @@
     extraCCByEmail.addAll(cc);
   }
 
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
-
     String threadId = getChangeMessageThreadId();
     setHeader("References", threadId);
 
@@ -71,6 +80,8 @@
       case OWNER_REVIEWERS:
         reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
+        removedReviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
+        addByEmail(RecipientType.TO, removedByEmailReviewers, true);
         break;
     }
 
@@ -96,10 +107,25 @@
     return names;
   }
 
+  public List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.name());
+    }
+    return names;
+  }
+
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContext.put("ownerName", getNameFor(change.getOwner()));
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("removedReviewerNames", getRemovedReviewerNames());
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 97fec4c..f4d6cd3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -732,10 +732,14 @@
     }
 
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(psId.changeId(), commit.name()), accountId, ts, psId);
-    changeMessage.setMessage(changeMsgString.get());
-    changeMessage.setTag(tag);
-    changeMessage.setRealAuthor(realAccountId);
+        ChangeMessage.create(
+            ChangeMessage.key(psId.changeId(), commit.name()),
+            accountId,
+            ts,
+            psId,
+            changeMsgString.get(),
+            realAccountId,
+            tag);
     allChangeMessages.add(changeMessage);
   }
 
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 3f17a2e..b99e2d2 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -162,6 +162,11 @@
       return Optional.empty();
     }
 
+    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+      logger.atFine().log("AutoMerge alredy exists");
+      return Optional.empty();
+    }
+
     ObjectId autoMerge;
     try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
diff --git a/java/com/google/gerrit/server/patch/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/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index dd00dca..f800207 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -523,7 +523,10 @@
                       + "who also have 'Push' rights on "
                       + RefNames.REFS_CONFIG);
             } else {
-              pde.setAdvice("To push into this reference you need 'Push' rights.");
+              pde.setAdvice(
+                  "Push to refs/for/"
+                      + RefNames.shortName(refName)
+                      + " to create a review, or get 'Push' rights to update the branch.");
             }
             break;
           case DELETE:
diff --git a/java/com/google/gerrit/server/project/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/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 6605c23..e0f7d91 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -166,7 +166,7 @@
   Iterable<ChangeData> byCommitsOnBranchNotMerged(
       Repository repo, BranchNameKey branch, Collection<String> hashes, int indexLimit)
       throws IOException {
-    if (hashes.size() > indexLimit) {
+    if (hashes.size() > indexLimit || !indexes.getSearchIndex().isEnabled()) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
     }
     return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
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/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index 1e1bade..dca969d 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.server.restapi.access;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.project.GetAccess;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -41,10 +47,15 @@
       usage = "projects for which the access rights should be returned")
   private List<String> projects = new ArrayList<>();
 
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
   private final GetAccess getAccess;
 
   @Inject
-  public ListAccess(GetAccess getAccess) {
+  public ListAccess(
+      PermissionBackend permissionBackend, ProjectCache projectCache, GetAccess getAccess) {
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
     this.getAccess = getAccess;
   }
 
@@ -53,7 +64,23 @@
       throws Exception {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      access.put(p, getAccess.apply(Project.nameKey(p)));
+      if (Strings.nullToEmpty(p).isEmpty()) {
+        continue;
+      }
+
+      Project.NameKey projectName = Project.nameKey(p);
+
+      if (!projectCache.get(projectName).isPresent()) {
+        throw new ResourceNotFoundException(projectName.get());
+      }
+
+      try {
+        permissionBackend.currentUser().project(projectName).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        throw new ResourceNotFoundException(projectName.get(), e);
+      }
+
+      access.put(p, getAccess.apply(projectName));
     }
     return Response.ok(access);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index b65f4ee..baa2951 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -46,8 +46,8 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -209,10 +209,10 @@
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
             .build();
-    groupsUpdate.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdate.get().updateGroup(groupUuid, groupDelta);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 0d12fd4..e6b4eee 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -107,8 +107,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListAccountsOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListAccountsOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index b207390..826c89d 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -16,7 +16,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -129,7 +128,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Abandon")
@@ -140,17 +139,9 @@
     if (!change.isNew()) {
       return description;
     }
-
-    try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     return description.setVisible(rsrc.permissions().testOrFalse(ChangePermission.ABANDON));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index ee6484c..b43585d 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -448,6 +448,9 @@
     if (workInProgress != null) {
       inserter.setWorkInProgress(workInProgress);
     }
+    if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
+      inserter.setWorkInProgress(false);
+    }
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -461,6 +464,20 @@
     return destChange.getId();
   }
 
+  /**
+   * We should set the change to be "ready for review" if: 1. workInProgress is not already set on
+   * this request. 2. The patch-set doesn't have any git conflict markers. 3. The change used to be
+   * work in progress (because of a previous patch-set).
+   */
+  private boolean shouldSetToReady(
+      CodeReviewCommit cherryPickCommit,
+      ChangeNotes destChangeNotes,
+      @Nullable Boolean workInProgress) {
+    return workInProgress == null
+        && cherryPickCommit.getFilesWithGitConflicts().isEmpty()
+        && destChangeNotes.getChange().isWorkInProgress();
+  }
+
   private Change.Id createNewChange(
       BatchUpdate bu,
       CodeReviewCommit cherryPickCommit,
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index edc8fcf..81b6fb3 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -190,7 +190,7 @@
       return CommentContextKey.builder()
           .project(project)
           .changeId(changeId)
-          .id(r.id)
+          .id(Url.decode(r.id)) // We reverse the encoding done while filling comment info
           .path(r.path)
           .patchset(r.patchSet)
           .contextPadding(contextPadding)
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 34af285..d1d4544 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -34,6 +34,9 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffMappings;
 import com.google.gerrit.server.patch.GitPositionTransformer;
@@ -142,10 +145,13 @@
       PatchSet targetPatchset,
       List<HumanComment> comments,
       List<HumanCommentFilter> filters) {
-
-    ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
-    ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
-    return port(changeNotes, targetPatchset, relevantComments);
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments", Metadata.builder().patchSetId(targetPatchset.number()).build())) {
+      ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
+      ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
+      return port(changeNotes, targetPatchset, relevantComments);
+    }
   }
 
   private ImmutableList<HumanCommentFilter> addDefaultFilters(
@@ -203,20 +209,29 @@
       PatchSet originalPatchset,
       PatchSet targetPatchset,
       ImmutableList<HumanComment> comments) {
-    Map<Short, List<HumanComment>> commentsPerSide =
-        comments.stream().collect(groupingBy(comment -> comment.side));
-    ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
-    for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
-      portedComments.addAll(
-          portSamePatchsetAndSide(
-              project,
-              change,
-              originalPatchset,
-              targetPatchset,
-              sideAndComments.getValue(),
-              sideAndComments.getKey()));
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments same patchset",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .build())) {
+      Map<Short, List<HumanComment>> commentsPerSide =
+          comments.stream().collect(groupingBy(comment -> comment.side));
+      ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
+      for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
+        portedComments.addAll(
+            portSamePatchsetAndSide(
+                project,
+                change,
+                originalPatchset,
+                targetPatchset,
+                sideAndComments.getValue(),
+                sideAndComments.getKey()));
+      }
+      return portedComments.build();
     }
-    return portedComments.build();
   }
 
   private ImmutableList<HumanComment> portSamePatchsetAndSide(
@@ -226,30 +241,40 @@
       PatchSet targetPatchset,
       List<HumanComment> comments,
       short side) {
-    ImmutableSet<Mapping> mappings;
-    try {
-      mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
-    } catch (Exception e) {
-      logger.atWarning().withCause(e).log(
-          "Could not determine some necessary diff mappings for porting comments on change %s from"
-              + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
-              + " destination.",
-          change.getChangeId(),
-          originalPatchset.id().getId(),
-          targetPatchset.id().getId(),
-          comments.size());
-      mappings = getFallbackMappings(comments);
-    }
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments same patchset and side",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .commentSide(side)
+                .build())) {
+      ImmutableSet<Mapping> mappings;
+      try {
+        mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "Could not determine some necessary diff mappings for porting comments on change %s from"
+                + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+                + " destination.",
+            change.getChangeId(),
+            originalPatchset.id().getId(),
+            targetPatchset.id().getId(),
+            comments.size());
+        mappings = getFallbackMappings(comments);
+      }
 
-    ImmutableList<PositionedEntity<HumanComment>> positionedComments =
-        comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
-    ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
-        positionTransformer.transform(positionedComments, mappings).stream()
-            .collect(
-                ImmutableMap.toImmutableMap(
-                    Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
-    collectMetrics(origToPortedMap);
-    return ImmutableList.copyOf(origToPortedMap.values());
+      ImmutableList<PositionedEntity<HumanComment>> positionedComments =
+          comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
+      ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
+          positionTransformer.transform(positionedComments, mappings).stream()
+              .collect(
+                  ImmutableMap.toImmutableMap(
+                      Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
+      collectMetrics(origToPortedMap);
+      return ImmutableList.copyOf(origToPortedMap.values());
+    }
   }
 
   private ImmutableSet<Mapping> loadMappings(
@@ -259,9 +284,18 @@
       PatchSet targetPatchset,
       short side)
       throws PatchListNotAvailableException {
-    ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
-    ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
-    return loadCommitMappings(project, originalCommit, targetCommit);
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Loading commit mappings",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .build())) {
+      ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+      ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
+      return loadCommitMappings(project, originalCommit, targetCommit);
+    }
   }
 
   private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
@@ -278,11 +312,15 @@
   private ImmutableSet<Mapping> loadCommitMappings(
       Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
       throws PatchListNotAvailableException {
-    PatchList patchList =
-        patchListCache.get(
-            PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
-            project);
-    return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) {
+      PatchList patchList =
+          patchListCache.get(
+              PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
+              project);
+      return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+    }
   }
 
   private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 842ed2a..c602214 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
@@ -34,7 +33,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -94,7 +93,7 @@
       IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
       deletedAssignee = deletedAssigneeUser.state();
       update.removeAssignee();
-      addMessage(ctx, update, deletedAssigneeUser);
+      addMessage(ctx, deletedAssigneeUser);
       return true;
     }
 
@@ -102,19 +101,18 @@
       return deletedAssignee != null ? deletedAssignee.account().id() : null;
     }
 
-    private void addMessage(
-        ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee) {
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Assignee deleted: " + deletedAssignee.getNameEmail(),
-              ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
-      cmUtil.addChangeMessage(update, cmsg);
+    private void addMessage(ChangeContext ctx, IdentifiedUser deletedAssignee) {
+      cmUtil.setChangeMessage(
+          ctx,
+          "Assignee deleted: "
+              + ChangeMessagesUtil.getAccountTemplate(deletedAssignee.getAccountId()),
+          ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
+    public void postUpdate(PostUpdateContext ctx) {
+      assigneeChanged.fire(
+          ctx.getChangeData(change), ctx.getAccount(), deletedAssignee, ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 5b44957..0f280db 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
@@ -85,7 +84,7 @@
     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
 
     String newChangeMessage =
-        createNewChangeMessage(user.asIdentifiedUser().getName(), input.reason);
+        createNewChangeMessage(user.asIdentifiedUser().getAccountId(), input.reason);
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
     try (BatchUpdate batchUpdate =
@@ -107,26 +106,29 @@
         changeMessagesUtil.byChange(notesFactory.createChecked(project, cId));
     ChangeMessage updatedChangeMessage = messages.get(targetIdx);
     AccountLoader accountLoader = accountLoaderFactory.create(true);
-    ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
+    ChangeMessageInfo info =
+        changeMessagesUtil.createChangeMessageInfoWithReplacedTemplates(
+            updatedChangeMessage, accountLoader);
     accountLoader.fill();
     return info;
   }
 
-  @VisibleForTesting
-  public static String createNewChangeMessage(String deletedBy, @Nullable String deletedReason) {
-    requireNonNull(deletedBy, "user name must not be null");
+  public static String createNewChangeMessage(
+      Account.Id deletedBy, @Nullable String deletedReason) {
+    requireNonNull(deletedBy, "user must not be null");
 
     if (Strings.isNullOrEmpty(deletedReason)) {
       return createNewChangeMessage(deletedBy);
     }
-    return String.format("Change message removed by: %s\nReason: %s", deletedBy, deletedReason);
+    return String.format(
+        "Change message removed by: %s\nReason: %s",
+        ChangeMessagesUtil.getAccountTemplate(deletedBy), deletedReason);
   }
 
-  @VisibleForTesting
-  public static String createNewChangeMessage(String deletedBy) {
-    requireNonNull(deletedBy, "user name must not be null");
+  public static String createNewChangeMessage(Account.Id deletedBy) {
+    requireNonNull(deletedBy, "user must not be null");
 
-    return "Change message removed by: " + deletedBy;
+    return "Change message removed by: " + ChangeMessagesUtil.getAccountTemplate(deletedBy);
   }
 
   private class DeleteChangeMessageOp implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index 3e4a483..db8e9de 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -64,7 +64,7 @@
       if (rsrc.isByEmail()) {
         op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
       } else {
-        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().state(), input);
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
       }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4b813df..8ae902d 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -21,7 +21,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -36,9 +35,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 +54,12 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.HashMap;
@@ -72,13 +74,14 @@
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
+  private final AddToAttentionSetOp.Factory attentionSetOpfactory;
+  private final Provider<CurrentUser> currentUserProvider;
 
   @Inject
   DeleteVote(
@@ -92,18 +95,21 @@
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator) {
+      MessageIdGenerator messageIdGenerator,
+      AddToAttentionSetOp.Factory attentionSetOpFactory,
+      Provider<CurrentUser> currentUserProvider) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.attentionSetOpfactory = attentionSetOpFactory;
+    this.currentUserProvider = currentUserProvider;
   }
 
   @Override
@@ -140,6 +146,14 @@
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
+      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+        bu.addOp(
+            change.getId(),
+            attentionSetOpfactory.create(
+                r.getReviewerUser().getAccountId(),
+                /* reason= */ "Their vote was deleted",
+                /* notify= */ false));
+      }
       bu.execute();
     }
 
@@ -152,7 +166,7 @@
     private final String label;
     private final DeleteVoteInput input;
 
-    private ChangeMessage changeMessage;
+    private String mailMessage;
     private Change change;
     private PatchSet ps;
     private Map<String, Short> newApprovals = new HashMap<>();
@@ -211,17 +225,15 @@
       StringBuilder msg = new StringBuilder();
       msg.append("Removed ");
       LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(userFactory.create(accountId).getNameEmail()).append("\n");
-      changeMessage =
-          ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      cmUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
-
+      msg.append(" by ").append(ChangeMessagesUtil.getAccountTemplate(accountId)).append("\n");
+      mailMessage =
+          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
       return true;
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      if (changeMessage == null) {
+    public void postUpdate(PostUpdateContext ctx) {
+      if (mailMessage == null) {
         return;
       }
 
@@ -232,7 +244,7 @@
           ReplyToChangeSender emailSender =
               deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
           emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
           emailSender.setNotify(notify);
           emailSender.setMessageId(
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
@@ -243,13 +255,13 @@
       }
 
       voteDeleted.fire(
-          change,
+          ctx.getChangeData(change),
           ps,
           accountState,
           newApprovals,
           oldApprovals,
           input.notify,
-          changeMessage.getMessage(),
+          mailMessage,
           user.state(),
           ctx.getWhen());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index c51bb91..740b8cb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -67,8 +67,9 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    EnumSet<ListChangesOption> optionSet = ListOption.fromHexString(ListChangesOption.class, hex);
+    options.addAll(optionSet);
   }
 
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index 15362d5..c6bbf53 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -35,7 +35,7 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
+  void setOptionFlagsHex(String hex) throws BadRequestException {
     delegate.setOptionFlagsHex(hex);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index b8902b7..2169d57 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -226,18 +226,24 @@
     }
 
     @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return webLinks.getEditLinks(projectName.get(), revB, sideB.fileName());
+    }
+
+    @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
-      String rev;
-      DiffSide side;
-      if (type == DiffSide.Type.SIDE_A) {
-        rev = revA;
-        side = sideA;
-      } else {
-        rev = revB;
-        side = sideB;
-      }
+      String rev = getSideRev(type);
+      DiffSide side = getDiffSide(type);
       return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
     }
+
+    private String getSideRev(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? revA : revB;
+    }
+
+    private DiffSide getDiffSide(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? sideA : sideB;
+    }
   }
 
   public GetDiff setBase(String base) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
index 6089778..99c8a0a 100644
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
@@ -137,6 +137,11 @@
     }
 
     @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(Type fileInfoType) {
       return ImmutableList.of();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
index af23ba7..08d51e7 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
@@ -62,8 +62,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListChangesOption.class, hex));
   }
 
   @Option(name = "--old", usage = "old NoteDb meta SHA-1")
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index 527129c..f3c0fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -34,6 +34,6 @@
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
-    return Response.withMustRevalidate(delegate.format(rsrc));
+    return Response.ok(delegate.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 65f90ae..89ee399 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -27,12 +27,10 @@
 import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index 099d0a6..c881621 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
-
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.Response;
@@ -48,7 +46,10 @@
     List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
         messages.stream()
-            .map(m -> createChangeMessageInfo(m, accountLoader))
+            .map(
+                m ->
+                    changeMessagesUtil.createChangeMessageInfoWithReplacedTemplates(
+                        m, accountLoader))
             .collect(Collectors.toList());
     accountLoader.fill();
     return Response.ok(messageInfos);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 8ec394c..22fcbc7 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -25,13 +25,11 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -252,9 +250,7 @@
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
-      cmUtil.addChangeMessage(update, cmsg);
+      cmUtil.setChangeMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
 
       return true;
     }
@@ -287,7 +283,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Move Change")
@@ -298,30 +294,15 @@
     if (!change.isNew()) {
       return description;
     }
-
-    try {
-      if (!projectCache
-          .get(rsrc.getProject())
-          .orElseThrow(illegalState(rsrc.getProject()))
-          .statePermitsWrite()) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .statePermitsWrite()) {
       return description;
     }
-
-    try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     return description.setVisible(
         and(
             permissionBackend.user(rsrc.getUser()).ref(change.getDest()).testCond(CREATE_CHANGE),
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 58321e9..4b70e04 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
@@ -43,7 +44,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
@@ -55,14 +55,14 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -91,13 +91,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 +123,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -170,8 +170,8 @@
   private final AccountResolver accountResolver;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
-  private final ReviewerAdder reviewerAdder;
-  private final AddReviewersEmail addReviewersEmail;
+  private final ReviewerModifier reviewerModifier;
+  private final ModifyReviewersEmail modifyReviewersEmail;
   private final NotifyResolver notifyResolver;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
@@ -196,8 +196,8 @@
       AccountResolver accountResolver,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      ReviewerAdder reviewerAdder,
-      AddReviewersEmail addReviewersEmail,
+      ReviewerModifier reviewerModifier,
+      ModifyReviewersEmail modifyReviewersEmail,
       NotifyResolver notifyResolver,
       @GerritServerConfig Config gerritConfig,
       WorkInProgressOp.Factory workInProgressOpFactory,
@@ -218,8 +218,8 @@
     this.accountResolver = accountResolver;
     this.email = email;
     this.commentAdded = commentAdded;
-    this.reviewerAdder = reviewerAdder;
-    this.addReviewersEmail = addReviewersEmail;
+    this.reviewerModifier = reviewerModifier;
+    this.modifyReviewersEmail = modifyReviewersEmail;
     this.notifyResolver = notifyResolver;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
@@ -276,15 +276,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 +313,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 +326,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 +336,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 +350,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 +385,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 +396,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 +438,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);
     }
   }
 
@@ -890,7 +906,7 @@
     private IdentifiedUser user;
     private ChangeNotes notes;
     private PatchSet ps;
-    private ChangeMessage message;
+    private String mailMessage;
     private List<Comment> comments = new ArrayList<>();
     private List<LabelVote> labelDelta = new ArrayList<>();
     private Map<String, Short> approvals = new HashMap<>();
@@ -928,8 +944,8 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      if (message == null) {
+    public void postUpdate(PostUpdateContext ctx) {
+      if (mailMessage == null) {
         return;
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
@@ -941,7 +957,8 @@
                   notes,
                   ps,
                   user,
-                  message,
+                  mailMessage,
+                  ctx.getWhen(),
                   comments,
                   in.message,
                   labelDelta,
@@ -952,7 +969,7 @@
               String.format("Repository %s not found", ctx.getProject().get()), ex);
         }
       }
-      String comment = message.getMessage();
+      String comment = mailMessage;
       if (publishPatchSetLevelComment) {
         // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
         // added event. For backwards compatibility, patchset level comment has a higher priority
@@ -968,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)
@@ -1468,10 +1491,9 @@
         return false;
       }
 
-      message =
-          ChangeMessagesUtil.newMessage(
-              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      cmUtil.addChangeMessage(ctx.getUpdate(psId), message);
+      mailMessage =
+          cmUtil.setChangeMessage(
+              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
       return true;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index e6a87e9..4691550 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -39,50 +39,51 @@
 
 @Singleton
 public class PostReviewers
-    implements RestCollectionModifyView<ChangeResource, ReviewerResource, AddReviewerInput> {
+    implements RestCollectionModifyView<ChangeResource, ReviewerResource, ReviewerInput> {
   private final BatchUpdate.Factory updateFactory;
   private final ChangeData.Factory changeDataFactory;
   private final NotifyResolver notifyResolver;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
 
   @Inject
   PostReviewers(
       BatchUpdate.Factory updateFactory,
       ChangeData.Factory changeDataFactory,
       NotifyResolver notifyResolver,
-      ReviewerAdder reviewerAdder) {
+      ReviewerModifier reviewerModifier) {
     this.updateFactory = updateFactory;
     this.changeDataFactory = changeDataFactory;
     this.notifyResolver = notifyResolver;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
   }
 
   @Override
-  public Response<AddReviewerResult> apply(ChangeResource rsrc, AddReviewerInput input)
+  public Response<ReviewerResult> apply(ChangeResource rsrc, ReviewerInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
 
-    ReviewerAddition addition = reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
-    if (addition.op == null) {
-      return Response.ok(addition.result);
+    ReviewerModification modification =
+        reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
+    if (modification.op == null) {
+      return Response.ok(modification.result);
     }
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, addition.op);
+      bu.addOp(id, modification.op);
       bu.execute();
     }
 
     // Re-read change to take into account results of the update.
-    addition.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
-    return Response.ok(addition.result);
+    modification.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
+    return Response.ok(modification.result);
   }
 
-  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, AddReviewerInput input)
+  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, ReviewerInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     NotifyHandling notifyHandling = input.notify;
     if (notifyHandling == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index 7b0d905..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/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index f442a42..7c54074 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
@@ -102,10 +101,7 @@
             String.format(
                 "Description of patch set %d changed to \"%s\"", psId.get(), newDescription);
       }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
-      cmUtil.addChangeMessage(update, cmsg);
+      cmUtil.setChangeMessage(update, summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index cf0d4cf..91fa2f0 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -82,8 +82,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListChangesOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index cfdf04d..3e1f033 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -215,7 +214,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
+  public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
@@ -226,27 +225,13 @@
     if (!(change.isNew() && rsrc.isCurrent())) {
       return description;
     }
-
-    try {
-      if (!projectCache
-          .get(rsrc.getProject())
-          .orElseThrow(illegalState(rsrc.getProject()))
-          .statePermitsWrite()) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .statePermitsWrite()) {
       return description;
     }
-
-    try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
 
@@ -256,14 +241,6 @@
       if (hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
-    } catch (Exception e) {
-      // Be generous here with the exceptions that we log and swallow. RebaseUtil#canRebase uses the
-      // change index and this UI action is on the critical path of rendering a change details page.
-      // If the index is broken, we log and disable the UI action, but still show the page to the
-      // user.
-      logger.atSevere().withCause(e).log(
-          "Failed to check if patch set can be rebased: %s", rsrc.getPatchSet());
-      return description;
     }
 
     if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index a1bd678..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..b2d1d3a 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -20,9 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -47,7 +45,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -113,7 +111,7 @@
 
     private Change change;
     private PatchSet patchSet;
-    private ChangeMessage message;
+    private String mailMessage;
 
     private Op(RestoreInput input) {
       this.input = input;
@@ -132,28 +130,27 @@
       change.setLastUpdatedOn(ctx.getWhen());
       update.setStatus(change.getStatus());
 
-      message = newMessage(ctx);
-      cmUtil.addChangeMessage(update, message);
+      mailMessage = cmUtil.setChangeMessage(ctx, commentMessage(), ChangeMessagesUtil.TAG_RESTORE);
       return true;
     }
 
-    private ChangeMessage newMessage(ChangeContext ctx) {
+    private String commentMessage() {
       StringBuilder msg = new StringBuilder();
       msg.append("Restored");
       if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
         msg.append("\n\n");
         msg.append(input.message.trim());
       }
-      return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_RESTORE);
+      return msg.toString();
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       try {
         ReplyToChangeSender emailSender =
             restoredSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
-        emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         emailSender.send();
@@ -161,12 +158,16 @@
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
       changeRestored.fire(
-          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
+          ctx.getChangeData(change),
+          patchSet,
+          ctx.getAccount(),
+          Strings.emptyToNull(input.message),
+          ctx.getWhen());
     }
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Restore")
@@ -177,27 +178,12 @@
     if (!change.isAbandoned()) {
       return description;
     }
-
-    try {
-      if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
       return description;
     }
-
-    try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     boolean visible = rsrc.permissions().testOrFalse(ChangePermission.RESTORE);
     return description.setVisible(visible);
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index fd4a13e..7bb43d2 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -22,7 +22,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -114,14 +113,8 @@
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
     Change change = rsrc.getChange();
-    boolean projectStatePermitsWrite = false;
-    try {
-      projectStatePermitsWrite =
-          projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
-    }
+    boolean projectStatePermitsWrite =
+        projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
     return new UiAction.Description()
         .setLabel("Revert")
         .setTitle("Revert the change")
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index cb91faa..20249df 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -28,8 +28,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -75,7 +73,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -614,10 +612,10 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
       changeReverted.fire(
-          change,
-          changeNotesFactory.createChecked(ctx.getProject(), revertChangeId).getChange(),
+          ctx.getChangeData(change),
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertChangeId)),
           ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
@@ -647,14 +645,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws Exception {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.getName(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.setChangeMessage(
+          ctx,
+          "Created a revert of this change as I" + computedChangeId.getName(),
+          ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/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 ba0720a..876c92c 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -102,14 +102,6 @@
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
 
-  public static class Output {
-    transient Change change;
-
-    private Output(Change c) {
-      change = c;
-    }
-  }
-
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final Provider<MergeOp> mergeOpProvider;
@@ -125,6 +117,7 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
+  private final ChangeJson.Factory json;
 
   @Inject
   Submit(
@@ -136,7 +129,8 @@
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeJson.Factory json) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.mergeOpProvider = mergeOpProvider;
@@ -170,10 +164,11 @@
     this.queryProvider = queryProvider;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
+    this.json = json;
   }
 
   @Override
-  public Response<Output> apply(RevisionResource rsrc, SubmitInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
@@ -189,12 +184,11 @@
         .orElseThrow(illegalState(rsrc.getProject()))
         .checkStatePermitsWrite();
 
-    return mergeChange(rsrc, submitter, input);
+    return Response.ok(json.noOptions().format(mergeChange(rsrc, submitter, input)));
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
-  public Response<Output> mergeChange(
-      RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
       throws RestApiException, IOException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
     Change change = rsrc.getChange();
@@ -215,7 +209,7 @@
 
       updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
-        return Response.ok(new Output(updatedChange));
+        return updatedChange;
       }
 
       throw new IllegalStateException(
@@ -290,22 +284,17 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
+  public UiAction.Description getDescription(RevisionResource resource)
+      throws IOException, PermissionBackendException {
     Change change = resource.getChange();
     if (!change.isNew() || !resource.isCurrent()) {
       return null; // submit not visible
     }
-
-    try {
-      if (!projectCache
-          .get(resource.getProject())
-          .map(ProjectState::statePermitsWrite)
-          .orElse(false)) {
-        return null; // submit not visible
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Error checking if change is submittable");
-      throw new StorageException("Could not determine problems for the change", e);
+    if (!projectCache
+        .get(resource.getProject())
+        .map(ProjectState::statePermitsWrite)
+        .orElse(false)) {
+      return null; // submit not visible
     }
 
     ChangeData cd = resource.getChangeResource().getChangeData();
@@ -315,13 +304,7 @@
       return null; // submit not visible
     }
 
-    ChangeSet cs;
-    try {
-      cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException("Could not determine complete set of changes to be submitted", e);
-    }
-
+    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -471,13 +454,11 @@
 
   public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
     private final Submit submit;
-    private final ChangeJson.Factory json;
     private final PatchSetUtil psUtil;
 
     @Inject
-    CurrentRevision(Submit submit, ChangeJson.Factory json, PatchSetUtil psUtil) {
+    CurrentRevision(Submit submit, PatchSetUtil psUtil) {
       this.submit = submit;
-      this.json = json;
       this.psUtil = psUtil;
     }
 
@@ -488,8 +469,7 @@
         throw new ResourceConflictException("current revision is missing");
       }
 
-      Response<Output> response = submit.apply(new RevisionResource(rsrc, ps), input);
-      return Response.ok(json.noOptions().format(response.value().change));
+      return submit.apply(new RevisionResource(rsrc, ps), input);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 71ff493..74f5290 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -18,7 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -99,12 +99,13 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", ReviewerAdder.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowed =
+        cfg.getInt("addreviewer", "maxAllowed", ReviewerModifier.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation =
         cfg.getInt(
             "addreviewer",
             "maxWithoutConfirmation",
-            ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+            ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
 
     logger.atFine().log("AccountVisibility: %s", av.name());
 
diff --git a/java/com/google/gerrit/server/restapi/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/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 0b05607..547c946 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -22,20 +22,15 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -84,21 +79,15 @@
     abstract ImmutableSet<String> hashes();
   }
 
-  private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
   private final Map<BranchNameKey, Optional<RevCommit>> heads;
-  private final ProjectCache projectCache;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
 
   @Inject
   LocalMergeSuperSetComputation(
-      PermissionBackend permissionBackend,
       Provider<InternalChangeQuery> queryProvider,
-      ProjectCache projectCache,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
@@ -107,51 +96,46 @@
 
   @Override
   public ChangeSet completeWithoutTopic(
-      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
-      throws IOException, PermissionBackendException {
+      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user) throws IOException {
     Collection<ChangeData> visibleChanges = new ArrayList<>();
     Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
     // For each target branch we run a separate rev walk to find open changes
     // reachable from changes already in the merge super set.
-    ImmutableListMultimap<BranchNameKey, ChangeData> bc =
-        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
-    for (BranchNameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(orm, b.project());
+    ImmutableSet<BranchNameKey> branches =
+        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges())).keySet();
+    ImmutableListMultimap<BranchNameKey, ChangeData> visibleChangesPerBranch =
+        byBranch(changeSet.changes());
+    ImmutableListMultimap<BranchNameKey, ChangeData> nonVisibleChangesPerBranch =
+        byBranch(changeSet.nonVisibleChanges());
+
+    for (BranchNameKey branchNameKey : branches) {
+      OpenRepo or = getRepo(orm, branchNameKey.project());
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
-      for (ChangeData cd : bc.get(b)) {
-        boolean visible = isVisible(changeSet, cd, user);
 
+      for (ChangeData cd : visibleChangesPerBranch.get(branchNameKey)) {
         if (submitType(cd) == SubmitType.CHERRY_PICK) {
-          if (visible) {
-            visibleChanges.add(cd);
-          } else {
-            nonVisibleChanges.add(cd);
-          }
-
-          continue;
-        }
-
-        // Get the underlying git commit object
-        RevCommit commit = or.rw.parseCommit(cd.currentPatchSet().commitId());
-
-        // Always include the input, even if merged. This allows
-        // SubmitStrategyOp to correct the situation later, assuming it gets
-        // returned by byCommitsOnBranchNotMerged below.
-        if (visible) {
-          visibleCommits.add(commit);
+          visibleChanges.add(cd);
         } else {
-          nonVisibleCommits.add(commit);
+          visibleCommits.add(or.rw.parseCommit(cd.currentPatchSet().commitId()));
+        }
+      }
+      for (ChangeData cd : nonVisibleChangesPerBranch.get(branchNameKey)) {
+        if (submitType(cd) == SubmitType.CHERRY_PICK) {
+          nonVisibleChanges.add(cd);
+        } else {
+          nonVisibleCommits.add(or.rw.parseCommit(cd.currentPatchSet().commitId()));
         }
       }
 
       Set<String> visibleHashes =
-          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, b);
-      Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
+          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, branchNameKey);
+      Set<String> nonVisibleHashes =
+          walkChangesByHashes(nonVisibleCommits, visibleHashes, or, branchNameKey);
 
       ChangeSet partialSet =
-          byCommitsOnBranchNotMerged(or, b, visibleHashes, nonVisibleHashes, user);
+          byCommitsOnBranchNotMerged(or, branchNameKey, visibleHashes, nonVisibleHashes, user);
       Iterables.addAll(visibleChanges, partialSet.changes());
       Iterables.addAll(nonVisibleChanges, partialSet.nonVisibleChanges());
     }
@@ -179,26 +163,6 @@
     }
   }
 
-  private boolean isVisible(ChangeSet changeSet, ChangeData cd, CurrentUser user)
-      throws PermissionBackendException {
-    boolean statePermitsRead =
-        projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false);
-    boolean visible = statePermitsRead && changeSet.ids().contains(cd.getId());
-    if (!visible) {
-      return false;
-    }
-
-    try {
-      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      // We thought the change was visible, but it isn't.
-      // This can happen if the ACL changes during the
-      // completeChangeSet computation, for example.
-      return false;
-    }
-  }
-
   private SubmitType submitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     if (!str.isOk()) {
@@ -207,7 +171,8 @@
     return str.type;
   }
 
-  private ChangeSet byCommitsOnBranchNotMerged(
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public ChangeSet byCommitsOnBranchNotMerged(
       OpenRepo or,
       BranchNameKey branch,
       Set<String> visibleHashes,
@@ -222,7 +187,9 @@
     ChangeIsVisibleToPredicate changeIsVisibleToPredicate =
         changeIsVisibleToPredicateFactory.forUser(user);
     for (ChangeData cd : potentiallyVisibleChanges) {
-      if (changeIsVisibleToPredicate.match(cd)) {
+      // short circuit permission checks for non-private changes, as we already checked all
+      // permissions (except for private changes).
+      if (!cd.change().isPrivate() || changeIsVisibleToPredicate.match(cd)) {
         visibleChanges.add(cd);
       } else {
         invisibleChanges.add(cd);
@@ -247,7 +214,8 @@
     return result;
   }
 
-  private Set<String> walkChangesByHashes(
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Set<String> walkChangesByHashes(
       Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index a8a8675..2b4fb3b 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -963,14 +962,8 @@
 
                   change.setStatus(Change.Status.ABANDONED);
 
-                  ChangeMessage msg =
-                      ChangeMessagesUtil.newMessage(
-                          change.currentPatchSetId(),
-                          internalUserFactory.create(),
-                          change.getLastUpdatedOn(),
-                          "Project was deleted.",
-                          ChangeMessagesUtil.TAG_MERGED);
-                  cmUtil.addChangeMessage(ctx.getUpdate(change.currentPatchSetId()), msg);
+                  cmUtil.setChangeMessage(
+                      ctx, "Project was deleted.", ChangeMessagesUtil.TAG_MERGED);
 
                   return true;
                 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index db48cce..cc3b75d 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -270,7 +270,7 @@
     }
 
     @Override
-    public void postUpdateImpl(Context ctx) {
+    public void postUpdateImpl(PostUpdateContext ctx) {
       if (rebaseOp != null) {
         rebaseOp.postUpdate(ctx);
       }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 69207ac..a63c7dc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -24,7 +24,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -48,7 +47,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -284,7 +283,7 @@
             // ChangeMergedEvent in the fixup case, but we'll just live with that.
             : alreadyMergedCommit;
     try {
-      setMerged(ctx, message(ctx, commit, s));
+      setMerged(ctx, commit, message(ctx, commit, s));
     } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
       logger.atSevere().withCause(err).log(msg);
@@ -395,17 +394,17 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+  private String message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt);
+      return message(ctx, txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
+      return message(ctx, txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
-      return message(ctx, commit.getPatchsetId(), txt);
+      return message(ctx, txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
       // Best effort to mimic the message that would have happened had this
       // succeeded the first time around.
@@ -437,19 +436,14 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body)
+  private String message(ChangeContext ctx, String body)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
     stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
-    return ChangeMessagesUtil.newMessage(
-        psId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        body + stickyApprovalDiff,
-        ChangeMessagesUtil.TAG_MERGED);
+    return body + stickyApprovalDiff;
   }
 
-  private void setMerged(ChangeContext ctx, ChangeMessage msg) {
+  private void setMerged(ChangeContext ctx, CodeReviewCommit commit, String msg) {
     Change c = ctx.getChange();
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
@@ -458,12 +452,13 @@
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
     if (msg != null) {
-      args.cmUtil.addChangeMessage(ctx.getUpdate(msg.getPatchSetId()), msg);
+      args.cmUtil.setChangeMessage(
+          ctx.getUpdate(commit.getPatchsetId()), msg, ChangeMessagesUtil.TAG_MERGED);
     }
   }
 
   @Override
-  public final void postUpdate(Context ctx) throws Exception {
+  public final void postUpdate(PostUpdateContext ctx) throws Exception {
     if (changeAlreadyMerged) {
       // TODO(dborowitz): This is suboptimal behavior in the presence of retries: postUpdate steps
       // will never get run for changes that submitted successfully on any but the final attempt.
@@ -518,7 +513,7 @@
     }
     if (mergeResultRev != null && !args.dryrun) {
       args.changeMerged.fire(
-          updatedChange,
+          ctx.getChangeData(updatedChange),
           mergedPatchSet,
           args.accountCache.get(submitter.accountId()).orElse(null),
           args.mergeTip.getCurrentTip().name(),
@@ -542,10 +537,10 @@
   }
 
   /**
-   * @see #postUpdate(Context)
+   * @see #postUpdate(PostUpdateContext)
    * @param ctx
    */
-  protected void postUpdateImpl(Context ctx) throws Exception {}
+  protected void postUpdateImpl(PostUpdateContext ctx) throws Exception {}
 
   /**
    * Amend the commit with gitlink update
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 7fdf833..3b0cd9a 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
@@ -74,9 +75,11 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
 import java.util.TreeMap;
+import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -134,7 +137,7 @@
     checkDifferentProject(updates);
 
     try {
-      List<ListenableFuture<?>> indexFutures = new ArrayList<>();
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
       List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
       try {
         for (BatchUpdate u : updates) {
@@ -156,7 +159,11 @@
         }
       }
 
-      ((ListenableFuture<?>) Futures.allAsList(indexFutures)).get();
+      Map<Change.Id, ChangeData> changeDatas =
+          Futures.allAsList(indexFutures).get().stream()
+              // filter out null values that were returned for change deletions
+              .filter(Objects::nonNull)
+              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
 
       // Fire ref update events only after all mutations are finished, since callers may assume a
       // patch set ref being created means the change was created, or a branch advancing meaning
@@ -165,7 +172,7 @@
 
       if (!dryrun) {
         for (BatchUpdate u : updates) {
-          u.executePostOps();
+          u.executePostOps(changeDatas);
         }
       }
     } catch (Exception e) {
@@ -340,6 +347,19 @@
     }
   }
 
+  private class PostUpdateContextImpl extends ContextImpl implements PostUpdateContext {
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    PostUpdateContextImpl(Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDatas = changeDatas;
+    }
+
+    @Override
+    public ChangeData getChangeData(Change change) {
+      return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
+    }
+  }
+
   /** Per-change result status from {@link #executeChangeOps}. */
   private enum ChangeResult {
     SKIPPED,
@@ -348,6 +368,7 @@
   }
 
   private final GitRepositoryManager repoManager;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -377,6 +398,7 @@
   BatchUpdate(
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
+      ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory changeNotesFactory,
       ChangeUpdate.Factory changeUpdateFactory,
       NoteDbUpdateManager.Factory updateManagerFactory,
@@ -386,6 +408,7 @@
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
     this.repoManager = repoManager;
+    this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.changeUpdateFactory = changeUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
@@ -589,12 +612,12 @@
       BatchUpdate.this.executed = manager.isExecuted();
     }
 
-    List<ListenableFuture<?>> startIndexFutures() {
+    List<ListenableFuture<ChangeData>> startIndexFutures() {
       if (dryrun) {
         return ImmutableList.of();
       }
       logDebug("Reindexing %d changes", results.size());
-      List<ListenableFuture<?>> indexFutures = new ArrayList<>(results.size());
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>(results.size());
       for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
         Change.Id id = e.getKey();
         switch (e.getValue()) {
@@ -687,8 +710,8 @@
     return new ChangeContextImpl(notes);
   }
 
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
+  private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
+    PostUpdateContextImpl ctx = new PostUpdateContextImpl(changeDatas);
     for (BatchUpdateOp op : ops.values()) {
       try (TraceContext.TraceTimer ignored =
           TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
new file mode 100644
index 0000000..d4d1f62
--- /dev/null
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/** Context for performing the {@link BatchUpdateOp#postUpdate} phase. */
+public interface PostUpdateContext extends Context {
+  /**
+   * Get the change data for the specified change.
+   *
+   * <p>If the change data has been computed previously, because the change has been indexed after
+   * an update or because this method has been invoked before, the cached change data instance is
+   * returned.
+   *
+   * @param change the change for which the change data should be returned
+   */
+  ChangeData getChangeData(Change change);
+
+  default ChangeData getChangeData(ChangeNotes changeNotes) {
+    return getChangeData(changeNotes.getChange());
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
index 7e9c47e..c3e3948 100644
--- a/java/com/google/gerrit/server/update/RepoOnlyOp.java
+++ b/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -35,5 +35,5 @@
    * @param ctx context
    */
   // TODO(dborowitz): Support async operations?
-  default void postUpdate(Context ctx) throws Exception {}
+  default void postUpdate(PostUpdateContext ctx) throws Exception {}
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 95627e1..4f23d1d 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
@@ -136,7 +136,7 @@
     // Add reviewers
     //
     for (String reviewer : toAdd) {
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = reviewer;
       input.confirmed = true;
       String error;
diff --git a/java/com/google/gerrit/testing/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..7130339 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -95,9 +95,9 @@
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -131,6 +131,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -919,11 +920,11 @@
 
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-      in = new AddReviewerInput();
+      in = new ReviewerInput();
       in.reviewer = user2.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -957,7 +958,7 @@
       sender.clear();
       requestScopeOperations.setApiUser(admin.id());
 
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
       List<Message> messages = sender.getMessages();
@@ -976,9 +977,9 @@
     // First reviewer added to the change
     ReviewInput input = new ReviewInput();
     input.reviewers = new ArrayList<>(1);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    input.reviewers.add(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    input.reviewers.add(reviewerInput);
     gApi.changes().id(r.getChangeId()).current().review(input);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -991,14 +992,14 @@
     // Second reviewer and existing reviewer added to the change
     ReviewInput input2 = new ReviewInput();
     input2.reviewers = new ArrayList<>(2);
-    AddReviewerInput addReviewerInput2 = new AddReviewerInput();
-    addReviewerInput2.reviewer = user.email();
-    input2.reviewers.add(addReviewerInput2);
-    AddReviewerInput addReviewerInput3 = new AddReviewerInput();
+    ReviewerInput reviewerInput2 = new ReviewerInput();
+    reviewerInput2.reviewer = user.email();
+    input2.reviewers.add(reviewerInput2);
+    ReviewerInput reviewerInput3 = new ReviewerInput();
 
     TestAccount user2 = accountCreator.user2();
-    addReviewerInput3.reviewer = user2.email();
-    input2.reviewers.add(addReviewerInput3);
+    reviewerInput3.reviewer = user2.email();
+    input2.reviewers.add(reviewerInput3);
 
     gApi.changes().id(r.getChangeId()).current().review(input2);
     List<Message> messages2 = sender.getMessages();
@@ -1012,13 +1013,13 @@
     // Existing reviewers re-added to the change: no notifications
     ReviewInput input3 = new ReviewInput();
     input3.reviewers = new ArrayList<>(2);
-    AddReviewerInput addReviewerInput4 = new AddReviewerInput();
-    addReviewerInput4.reviewer = user.email();
-    input3.reviewers.add(addReviewerInput4);
-    AddReviewerInput addReviewerInput5 = new AddReviewerInput();
+    ReviewerInput reviewerInput4 = new ReviewerInput();
+    reviewerInput4.reviewer = user.email();
+    input3.reviewers.add(reviewerInput4);
+    ReviewerInput reviewerInput5 = new ReviewerInput();
 
-    addReviewerInput5.reviewer = user2.email();
-    input3.reviewers.add(addReviewerInput5);
+    reviewerInput5.reviewer = user2.email();
+    input3.reviewers.add(reviewerInput5);
 
     gApi.changes().id(r.getChangeId()).current().review(input3);
     List<Message> messages3 = sender.getMessages();
@@ -1030,9 +1031,9 @@
     PushOneCommit.Result r = createChange();
 
     // First reviewer added to the change
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
@@ -1043,9 +1044,9 @@
 
     // Second reviewer added to the change
     TestAccount user2 = accountCreator.user2();
-    AddReviewerInput addReviewerInput2 = new AddReviewerInput();
-    addReviewerInput2.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput2);
+    ReviewerInput reviewerInput2 = new ReviewerInput();
+    reviewerInput2.reviewer = user2.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput2);
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
@@ -1055,9 +1056,9 @@
     sender.clear();
 
     // Exiting reviewer re-added to the change: no notifications
-    AddReviewerInput addReviewerInput3 = new AddReviewerInput();
-    addReviewerInput3.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput3);
+    ReviewerInput reviewerInput3 = new ReviewerInput();
+    reviewerInput3.reviewer = user2.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput3);
     List<Message> messages3 = sender.getMessages();
     assertThat(messages3).isEmpty();
   }
@@ -2625,12 +2626,14 @@
       }
       assertStaleAccountAndReindex(accountId);
 
+      extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
       extIdNotes.upsert(ExternalId.createWithEmail(key, accountId, "foo@example.com"));
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
       }
       assertStaleAccountAndReindex(accountId);
 
+      extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
       extIdNotes.delete(accountId, key);
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
@@ -2886,6 +2889,91 @@
     assertThat(thrown).hasMessageThat().contains("username");
   }
 
+  @Test
+  public void externalIdBatchUpdates() throws Exception {
+    String extId1String = "foo:bar";
+    String extId2String = "foo:baz";
+    ExternalId extId1 =
+        ExternalId.createWithEmail(ExternalId.Key.parse(extId1String), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        ExternalId.createWithEmail(ExternalId.Key.parse(extId2String), user.id(), "2@foo.com");
+
+    ObjectId revBefore;
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      revBefore = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+    }
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+    ImmutableList<Optional<AccountState>> accountStates =
+        accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    assertThat(accountStates).hasSize(2);
+    assertThat(accountStates.get(0).get().externalIds()).contains(extId1);
+    assertThat(accountStates.get(1).get().externalIds()).contains(extId2);
+    assertThat(
+            gApi.accounts().id(admin.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toSet()))
+        .contains(extId1String);
+    assertThat(
+            gApi.accounts().id(user.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toSet()))
+        .contains(extId2String);
+
+    // Ensure that we only applied one single commit.
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit after = rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+      assertThat(after.getParent(0).toObjectId()).isEqualTo(revBefore);
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_fail_sameAccount() {
+    ExternalId extId1 =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:baz"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    // Another update for the same account is not allowed.
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId2));
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2)));
+    assertThat(e).hasMessageThat().contains("updates must all be for different accounts");
+  }
+
+  @Test
+  public void externalIdBatchUpdates_fail_duplicateKey() {
+    ExternalId extIdAdmin =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extIdUser =
+        ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extIdAdmin));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extIdUser));
+    DuplicateExternalIdKeyException e =
+        assertThrows(
+            DuplicateExternalIdKeyException.class,
+            () -> accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2)));
+    assertThat(e).hasMessageThat().contains("foo:bar");
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index f78cb9ab..d1258fc 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -59,7 +59,7 @@
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.index(accountId);
@@ -75,7 +75,7 @@
     loadAccountToCache(accountId);
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.index(accountId);
@@ -90,7 +90,7 @@
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.reindexIfStale(accountId);
@@ -104,7 +104,7 @@
   public void notStaleAccountIsNotReindexed() throws Exception {
     Account.Id accountId = createAccount("foo");
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail("foo@example.com").build());
+        accountId, newAccountDelta().setPreferredEmail("foo@example.com").build());
     accountIndexer.index(accountId);
 
     boolean reindexed = accountIndexer.reindexIfStale(accountId);
@@ -115,7 +115,7 @@
   public void indexStalenessIsNotDerivedFromCacheStaleness() throws Exception {
     Account.Id accountId = createAccount("foo");
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail("foo@example.com").build());
+        accountId, newAccountDelta().setPreferredEmail("foo@example.com").build());
     reloadAccountToCache(accountId);
 
     boolean reindexed = accountIndexer.reindexIfStale(accountId);
@@ -135,12 +135,11 @@
     accountCache.get(accountId);
   }
 
-  private static InternalAccountUpdate.Builder newAccountUpdate() {
-    return InternalAccountUpdate.builder();
+  private static AccountDelta.Builder newAccountDelta() {
+    return AccountDelta.builder();
   }
 
-  private void updateAccountWithoutCacheOrIndex(
-      Account.Id accountId, InternalAccountUpdate accountUpdate)
+  private void updateAccountWithoutCacheOrIndex(Account.Id accountId, AccountDelta accountDelta)
       throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
         MetaDataUpdate md =
@@ -150,7 +149,7 @@
       md.getCommitBuilder().setCommitter(ident);
 
       AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-      accountConfig.setAccountUpdate(accountUpdate);
+      accountConfig.setAccountDelta(accountDelta);
       accountConfig.commit(md);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 517cf89..7be7114 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();
@@ -2391,12 +2434,14 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
+    assertThat(message.body()).contains("Removed reviewer " + user.getNameEmail() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
     // Make sure the change message for removing a reviewer is correct.
     assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
-        .contains("Removed reviewer " + user.fullName());
+        .isEqualTo("Removed reviewer " + user.getNameEmail() + ".");
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo("Removed reviewer " + ChangeMessagesUtil.getAccountTemplate(user.id()) + ".");
 
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
@@ -2417,10 +2462,10 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     // Add a cc
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = CC;
-    addReviewerInput.reviewer = user.id().toString();
-    gApi.changes().id(changeId).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
 
     // Remove a cc
     sender.clear();
@@ -2430,11 +2475,14 @@
     // Make sure the email for removing a cc is correct.
     assertThat(sender.getMessages()).hasSize(1);
     Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed cc " + user.fullName() + ".");
+    assertThat(message.body()).contains("Removed cc " + user.getNameEmail() + ".");
 
     // Make sure the change message for removing a reviewer is correct.
     assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
-        .contains("Removed cc " + user.fullName());
+        .isEqualTo("Removed cc " + user.getNameEmail() + ".");
+
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo("Removed cc " + ChangeMessagesUtil.getAccountTemplate(user.id()) + ".");
   }
 
   @Test
@@ -2474,8 +2522,21 @@
       assertThat(sender.getMessages()).hasSize(1);
       Message message = sender.getMessages().get(0);
       assertThat(message.body())
-          .contains("Removed reviewer " + user.fullName() + " with the following votes");
-      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName());
+          .contains("Removed reviewer " + user.getNameEmail() + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.getNameEmail());
+      ChangeMessageInfo changeMessageInfo =
+          Iterables.getLast(gApi.changes().id(changeId).messages());
+      assertThat(changeMessageInfo.message)
+          .contains("Removed reviewer " + user.getNameEmail() + " with the following votes");
+      assertThat(changeMessageInfo.message).contains("* Code-Review+1 by " + user.getNameEmail());
+      changeMessageInfo = Iterables.getLast(gApi.changes().id(changeId).get().messages);
+      assertThat(changeMessageInfo.message)
+          .contains(
+              "Removed reviewer "
+                  + ChangeMessagesUtil.getAccountTemplate(user.id())
+                  + " with the following votes");
+      assertThat(changeMessageInfo.message)
+          .contains("* Code-Review+1 by " + ChangeMessagesUtil.getAccountTemplate(user.id()));
     } else {
       assertThat(sender.getMessages()).isEmpty();
     }
@@ -2591,7 +2652,11 @@
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            "Removed Code-Review+1 by " + ChangeMessagesUtil.getAccountTemplate(user.id()) + "\n");
+    assertThat(gApi.changes().id(r.getChangeId()).message(message.id).get().message)
+        .isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
@@ -2708,7 +2773,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 +2970,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 +3078,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 +3227,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);
     }
@@ -3974,6 +4054,58 @@
     submittableAfterLosingPermissions("Label");
   }
 
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantSubmitWithInvisibleChangesWithTopic() throws Exception {
+    createBranch(BranchNameKey.create(project, "secret"));
+
+    // create two changes in the same topic.
+    String topic = "topic";
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange("refs/for/secret");
+    approve(r1.getChangeId());
+    approve(r2.getChangeId());
+    gApi.changes().id(r1.getChangeId()).topic(topic);
+    gApi.changes().id(r2.getChangeId()).topic(topic);
+
+    // make one of the changes invisible.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+        .update();
+
+    // can't submit with invisible changes.
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(AuthException.class, () -> gApi.changes().id(r1.getChangeId()).current().submit());
+  }
+
+  @Test
+  public void cantSubmitWithInvisibleDependentChange() throws Exception {
+    // create two dependent changes.
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    approve(r1.getChangeId());
+    approve(r2.getChangeId());
+
+    // make the dependent change invisible.
+    gApi.changes().id(r1.getChangeId()).setPrivate(true);
+
+    // can't submit with invisible changes.
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(AuthException.class, () -> gApi.changes().id(r2.getChangeId()).current().submit());
+  }
+
   private void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = LabelId.CODE_REVIEW;
     AccountGroup.UUID registered = REGISTERED_USERS;
@@ -4218,11 +4350,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 +4447,7 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index c8b1715..b79be80 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -40,12 +41,14 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -54,15 +57,19 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -71,6 +78,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -677,6 +685,199 @@
     }
   }
 
+  @Test
+  public void submitRulesAreInvokedOnlyOnce() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+    }
+
+    assertThat(testSubmitRule.count).isEqualTo(1);
+  }
+
+  @Test
+  public void deletingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+
+    // add user and user2
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    sender.clear();
+
+    // remove user and user2
+    ReviewResult reviewResult =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .review(
+                ReviewInput.create()
+                    .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                    .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true));
+
+    assertThat(
+            reviewResult.reviewers.values().stream()
+                .map(a -> a.removed.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user.fullName(), user2.fullName());
+
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers()).isEmpty();
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")");
+  }
+
+  @Test
+  public void addingAndDeletingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+    TestAccount user3 = accountCreator.create("user3", "user3@email.com", "user3", "user3");
+    TestAccount user4 = accountCreator.create("user4", "user4@email.com", "user4", "user4");
+
+    // add user and user2
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    sender.clear();
+
+    // remove user and user2 while adding user3 and user4
+    ReviewResult reviewResult =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .review(
+                ReviewInput.create()
+                    .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                    .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                    .reviewer(user3.email())
+                    .reviewer(user4.email()));
+
+    assertThat(
+            reviewResult.reviewers.values().stream()
+                .filter(a -> a.removed != null)
+                .map(a -> a.removed.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user.fullName(), user2.fullName());
+    assertThat(
+            reviewResult.reviewers.values().stream()
+                .filter(a -> a.reviewers != null)
+                .map(a -> Iterables.getOnlyElement(a.reviewers).name)
+                .collect(toImmutableSet()))
+        .containsExactly(user3.fullName(), user4.fullName());
+
+    assertThat(
+            gApi.changes().id(r.getChangeId()).reviewers().stream()
+                .map(a -> a.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user3.fullName(), user4.fullName());
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("Hello ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user3.fullName(), user4.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user4.fullName(), user3.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            "("
+                + Pattern.quote(String.format("%s and %s", user3.fullName(), user4.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user4.fullName(), user3.fullName()))
+                + ")"
+                + Pattern.quote(" to <strong>review</strong> this change"));
+
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")");
+  }
+
+  @Test
+  public void deletingNonExistingReviewerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .current()
+                    .review(
+                        ReviewInput.create()
+                            .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)));
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Reviewer %s doesn't exist in the change, hence can't delete it", user.fullName()));
+  }
+
+  @Test
+  public void addingAndDeletingSameReviewerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .current()
+                    .review(
+                        ReviewInput.create()
+                            .reviewer(user.email())
+                            .reviewer(user.email(), ReviewerState.REMOVED, true)));
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Reviewer %s doesn't exist in the change," + " hence can't delete it",
+                user.fullName()));
+  }
+
   private static class TestListener implements CommentAddedListener {
     public CommentAddedListener.Event lastCommentAddedEvent;
 
@@ -753,4 +954,14 @@
       assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
     }
   }
+
+  private static class TestSubmitRule implements SubmitRule {
+    int count;
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      count++;
+      return Optional.empty();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 14704ad..31381dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -24,8 +24,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -51,7 +51,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
-@NoHttpd
 public class QueryChangesIT extends AbstractDaemonTest {
   @Inject private AccountOperations accountOperations;
   @Inject private ProjectOperations projectOperations;
@@ -334,6 +333,13 @@
   }
 
   @Test
+  public void testInvalidListChangeOption() throws Exception {
+    PushOneCommit.Result r = createChange();
+    RestResponse rep = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=fffffff");
+    rep.assertBadRequest();
+  }
+
+  @Test
   @SuppressWarnings("unchecked")
   public void skipVisibility_privateChange() throws Exception {
     TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 5c59129..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/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
new file mode 100644
index 0000000..32e8232
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -0,0 +1,1015 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.GrantRevertPermission;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessIT extends AbstractDaemonTest {
+
+  private static final String REFS_ALL = Constants.R_REFS + "*";
+  private static final String REFS_HEADS = Constants.R_HEADS + "*";
+  private static final String REFS_META_VERSION = "refs/meta/version";
+  private static final String REFS_DRAFTS = "refs/draft-comments/*";
+  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private GrantRevertPermission grantRevertPermission;
+
+  private Project.NameKey newProjectName;
+
+  @Before
+  public void setUp() throws Exception {
+    newProjectName = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void grantRevertPermission() throws Exception {
+    String ref = "refs/*";
+    String groupId = "global:Registered-Users";
+
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+    assertThat(info.local.containsKey(ref)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(ref);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
+    String refsHeads = "refs/heads/*";
+    String refsStar = "refs/*";
+    String groupId = "global:Registered-Users";
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+
+    // Revert permission is removed on refs/heads/*.
+    assertThat(info.local.containsKey(refsHeads)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
+
+    // new permission is added on refs/* with Registered-Users.
+    assertThat(info.local.containsKey(refsStar)).isTrue();
+    accessSectionInfo = info.local.get(refsStar);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+            grant(projectConfig, heads, Permission.REVERT, otherGroup);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo expected = pApi().access();
+
+    grantRevertPermission.execute(newProjectName);
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo actual = pApi().access();
+    // Permissions don't change
+    assertThat(expected.local).isEqualTo(actual.local);
+  }
+
+  @Test
+  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
+    grantRevertPermission.execute(newProjectName);
+    grantRevertPermission.execute(newProjectName);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
+
+      Permission permission = all.getPermission(Permission.REVERT);
+      assertThat(permission.getRules()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void getDefaultInheritance() throws Exception {
+    String inheritedName = pApi().access().inheritsFrom.name;
+    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  private Registration newFileHistoryWebLink() {
+    FileHistoryWebLink weblink =
+        new FileHistoryWebLink() {
+          @Override
+          public WebLinkInfo getFileHistoryWebLink(
+              String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(weblink);
+  }
+
+  @Test
+  public void webLink() throws Exception {
+    try (Registration registration = newFileHistoryWebLink()) {
+      ProjectAccessInfo info = pApi().access();
+      assertThat(info.configWebLinks).hasSize(1);
+      assertThat(info.configWebLinks.get(0).url)
+          .isEqualTo("http://view/" + newProjectName + "/project.config");
+    }
+  }
+
+  @Test
+  public void webLinkNoRefsMetaConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName);
+        Registration registration = newFileHistoryWebLink()) {
+      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
+      u.setForceUpdate(true);
+      assertThat(u.delete()).isEqualTo(Result.FORCED);
+
+      // This should not crash.
+      pApi().access();
+    }
+  }
+
+  @Test
+  public void addAccessSection() throws Exception {
+    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+
+    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(
+        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
+  }
+
+  @Test
+  public void addAccessSectionForPluginPermission() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new PluginProjectPermissionDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Project Permission";
+                  }
+                },
+                "fooPermission")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(
+          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
+
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
+      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
+
+      assertThat(pApi().access().local).isEqualTo(accessInput.add);
+    }
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("label-Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
+  }
+
+  @Test
+  public void createAccessChangeNop() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+  }
+
+  @Test
+  public void createAccessChangeEmptyConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(Result.FORCED);
+
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSection = newAccessSectionInfo();
+      PermissionInfo read = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
+      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSection.permissions.put(Permission.READ, read);
+      accessInput.add.put(REFS_HEADS, accessSection);
+
+      ChangeInfo out = pApi().accessChange(accessInput);
+      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    }
+  }
+
+  @Test
+  public void createAccessChange() throws Exception {
+    projectOperations
+        .project(newProjectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    // User can see the branch
+    requestScopeOperations.setApiUser(user.id());
+    pApi().branch("refs/heads/master").get();
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    // Deny read to registered users.
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    read.exclusive = true;
+    accessSection.permissions.put(Permission.READ, read);
+    accessInput.add.put(REFS_HEADS, accessSection);
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo out = pApi().accessChange(accessInput);
+
+    assertThat(out.project).isEqualTo(newProjectName.get());
+    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(out.submitted).isNull();
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
+    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
+
+    ReviewInput reviewIn = new ReviewInput();
+    reviewIn.label("Code-Review", (short) 2);
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // check that the change took effect.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
+
+    // Restore.
+    accessInput.add.clear();
+    accessInput.remove.put(REFS_HEADS, accessSection);
+    requestScopeOperations.setApiUser(user.id());
+
+    requestScopeOperations.setApiUser(admin.id());
+    out = pApi().accessChange(accessInput);
+
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // Now it works again.
+    requestScopeOperations.setApiUser(user.id());
+    pApi().branch("refs/heads/master").get();
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    accessSectionToRemove.permissions.put(
+        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRule() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission rule
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput
+        .add
+        .get(REFS_HEADS)
+        .permissions
+        .get(Permission.LABEL + LabelId.CODE_REVIEW)
+        .rules
+        .remove(SystemGroupBackend.REGISTERED_USERS.get());
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission rules
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void getPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
+  }
+
+  @Test
+  public void setPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Create a change to apply
+    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
+    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
+  }
+
+  @Test
+  public void permissionsGroupMap() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo read = newPermissionInfo();
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    accessInput.add.put(REFS_ALL, accessSection);
+    ProjectAccessInfo result = pApi().access(accessInput);
+    assertThatMap(result.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    // Check the name, which is what the UI cares about; exhaustive
+    // coverage of GroupInfo should be in groups REST API tests.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    // Strip the ID, since it is in the key.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // Get call returns groups too.
+    ProjectAccessInfo loggedInResult = pApi().access();
+    assertThatMap(loggedInResult.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
+    assertThat(owners.name).isEqualTo("Project Owners");
+    assertThat(owners.id).isNull();
+    assertThat(owners.members).isNull();
+    assertThat(owners.includes).isNull();
+
+    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
+    requestScopeOperations.setApiUserAnonymous();
+    ProjectAccessInfo anonResult = pApi().access();
+    assertThatMap(anonResult.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+  }
+
+  @Test
+  public void updateParentAsUser() throws Exception {
+    // Create child
+    String newParentProjectName = projectOperations.newProject().create().get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
+  }
+
+  @Test
+  public void updateParentAsAdministrator() throws Exception {
+    // Create parent
+    String newParentProjectName = projectOperations.newProject().create().get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    pApi().access(accessInput);
+
+    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void addGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedAccessSectionInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void addPluginGlobalCapability() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new CapabilityDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Global Capability";
+                  }
+                },
+                "fooCapability")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
+
+      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+      ProjectAccessInfo updatedAccessSectionInfo =
+          gApi.projects().name(allProjects.get()).access(accessInput);
+      assertThatMap(
+              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+          .keys()
+          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+    }
+  }
+
+  @Test
+  public void addPermissionAsGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put(Permission.PUSH, push);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
+  }
+
+  @Test
+  public void addInvalidGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo("Unknown global capability: Invalid Global Capability");
+  }
+
+  @Test
+  public void addGlobalCapabilityForNonRootProject() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+  }
+
+  @Test
+  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
+
+    // Add and validate first as removing existing privileges such as
+    // administrateServer would break upcoming tests
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+
+    // Remove
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsNoneIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void unknownPermissionRemainsUnchanged() throws Exception {
+    String access = "access";
+    String unknownPermission = "unknownPermission";
+    String registeredUsers = "group Registered Users";
+    String refsFor = "refs/for/*";
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push unknown permission
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    // Verify that unknownPermission is present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
+
+    // Make permission change through API
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+    accessInput.add.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+    accessInput.add.clear();
+    accessInput.remove.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Verify that unknownPermission is still present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
+  }
+
+  @Test
+  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = project.get();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allUsers.get()).access(accessInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(allUsers.get() + " must inherit from " + allProjects.get());
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules).values().containsExactly(pri);
+
+    // Revoke the permission
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
+      throws Exception {
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Grant CREATE_GROUP to Administrators
+    accessInput = newProjectAccessInput();
+    accessSection = newAccessSectionInfo();
+    createGroup = newPermissionInfo();
+    createGroup.rules.put(adminGroupUuid().get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules)
+        .keys()
+        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
+    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
+    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
+  }
+
+  @Test
+  public void addAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  @Test
+  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  private ProjectApi pApi() throws Exception {
+    return gApi.projects().name(newProjectName.get());
+  }
+
+  private ProjectAccessInput newProjectAccessInput() {
+    ProjectAccessInput p = new ProjectAccessInput();
+    p.add = new HashMap<>();
+    p.remove = new HashMap<>();
+    return p;
+  }
+
+  private PermissionInfo newPermissionInfo() {
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules = new HashMap<>();
+    return p;
+  }
+
+  private AccessSectionInfo newAccessSectionInfo() {
+    AccessSectionInfo a = new AccessSectionInfo();
+    a.permissions = new HashMap<>();
+    return a;
+  }
+
+  private AccessSectionInfo createDefaultAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    pri.max = 1;
+    pri.min = -1;
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo email = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createAccessSectionInfoDenyAll() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    return accessSection;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 0b18503..9bdc420 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,23 @@
   }
 
   @Test
+  public void editWebLinkIncludedInDiff() throws Exception {
+    try (Registration registration = newEditWebLink()) {
+      String fileName = "a_new_file.txt";
+      String fileContent = "First line\nSecond line\n";
+      PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+      DiffInfo info =
+          gApi.changes()
+              .id(result.getChangeId())
+              .revision(result.getCommit().name())
+              .file(fileName)
+              .diff();
+      assertThat(info.editWebLinks).hasSize(1);
+      assertThat(info.editWebLinks.get(0).url).isEqualTo("http://edit/" + project + "/" + fileName);
+    }
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -2875,6 +2899,18 @@
     assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
   }
 
+  private Registration newEditWebLink() {
+    EditWebLink webLink =
+        new EditWebLink() {
+          @Override
+          public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://edit/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(webLink);
+  }
+
   private String updatedCommitMessage() {
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index abfd7896..9d0b1f4 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -93,6 +93,8 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
@@ -152,7 +154,8 @@
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    gApi.changes().id(changeId).current().submit();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).current().submit();
+    assertThat(changeInfo.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -1196,6 +1199,23 @@
   }
 
   @Test
+  public void cherryPickSetsReadyChangeOnNewPatchset() throws Exception {
+    PushOneCommit.Result result = pushTo("refs/for/master");
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+    ChangeApi originalChange = gApi.changes().id(project.get() + "~master~" + result.getChangeId());
+
+    ChangeApi cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    cherryPick.setWorkInProgress();
+    cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+
+    ChangeInfo secondCherryPickResult = cherryPick.get(ALL_REVISIONS);
+    assertThat(secondCherryPickResult.revisions).hasSize(2);
+    assertThat(secondCherryPickResult.workInProgress).isNull();
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -1614,16 +1634,26 @@
 
   @Test
   public void commit() throws Exception {
-    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
-    PatchSetWebLink link =
+    WebLinkInfo expectedPatchSetLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
+    PatchSetWebLink patchSetLink =
         new PatchSetWebLink() {
           @Override
           public WebLinkInfo getPatchSetWebLink(
               String projectName, String commit, String commitMessage, String branchName) {
-            return expectedWebLinkInfo;
+            return expectedPatchSetLinkInfo;
           }
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
+    WebLinkInfo expectedResolveConflictsLinkInfo = new WebLinkInfo("bar", "img", "resolve");
+    ResolveConflictsWebLink resolveConflictsLink =
+        new ResolveConflictsWebLink() {
+          @Override
+          public WebLinkInfo getResolveConflictsWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
+            return expectedResolveConflictsLinkInfo;
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(patchSetLink).add(resolveConflictsLink)) {
       PushOneCommit.Result r = createChange();
       RevCommit c = r.getCommit();
 
@@ -1640,11 +1670,20 @@
 
       commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
       assertThat(commitInfo.webLinks).hasSize(1);
-      WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
-      assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
-      assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
-      assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
-      assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
+      WebLinkInfo patchSetLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
+      assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
+      assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
+      assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);
+      assertThat(patchSetLinkInfo.target).isEqualTo(expectedPatchSetLinkInfo.target);
+
+      assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
+      WebLinkInfo resolveCommentsLinkInfo =
+          Iterables.getOnlyElement(commitInfo.resolveConflictsWebLinks);
+      assertThat(resolveCommentsLinkInfo.name).isEqualTo(expectedResolveConflictsLinkInfo.name);
+      assertThat(resolveCommentsLinkInfo.imageUrl)
+          .isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
+      assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
+      assertThat(resolveCommentsLinkInfo.target).isEqualTo(expectedResolveConflictsLinkInfo.target);
     }
   }
 
@@ -1876,7 +1915,10 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            String.format(
+                "Removed Code-Review+1 by %s\n", ChangeMessagesUtil.getAccountTemplate(user.id())));
     assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 8233f0c..a90cd56 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -46,11 +46,11 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -154,7 +154,7 @@
   public void publishEdit() throws Exception {
     createArbitraryEditFor(changeId);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -209,7 +209,7 @@
 
   @Test
   public void publishEditNotifyRest() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -224,7 +224,7 @@
 
   @Test
   public void publishEditWithDefaultNotify() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index eac0f1b..45d1b76 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -674,7 +674,7 @@
     GroupInput gin = new GroupInput();
     gin.name = group;
     gin.members = ImmutableList.of(user.username(), user2.username());
-    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerModifier.
     gApi.groups().create(gin);
 
     PushOneCommit.Result r = pushTo("refs/for/master%cc=" + group);
@@ -752,7 +752,7 @@
     GroupInput gin = new GroupInput();
     gin.name = group;
     gin.members = ImmutableList.of(user.username(), user2.username());
-    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerModifier.
     gApi.groups().create(gin);
 
     PushOneCommit.Result r = pushTo("refs/for/master%r=" + group);
@@ -2823,6 +2823,14 @@
     r.assertErrorStatus("\"--unknown\" is not a valid option");
   }
 
+  @Test
+  public void pushForMagicBranchWithSkipValidationOptionIsNotAllowed() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--skip-validation\" option is only supported for direct push");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
index 46688dd..b16394d 100644
--- a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -166,6 +166,25 @@
     assertNoAutoMergeCreated(result.getCommit());
   }
 
+  @Test
+  public void pushWorksIfAutoMergeExists() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+
+    // Delete change and push commit again.
+    gApi.changes().id(result.getChangeId()).delete();
+
+    // Push again successfully and check that AutoMerge commit is still there
+    result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+  }
+
   private void assertAutoMergeCreated(ObjectId mergeCommit) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 385780b..9d1bdaa 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -103,7 +103,7 @@
     assertThat(r)
         .hasMessages(
             "error: branch refs/heads/master:",
-            "To push into this reference you need 'Push' rights.",
+            "Push to refs/for/master to create a review, or get 'Push' rights to update the branch.",
             "User: admin",
             "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
@@ -183,7 +183,7 @@
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "error: branch refs/heads/master:",
-            "To push into this reference you need 'Push' rights.",
+            "Push to refs/for/master to create a review, or get 'Push' rights to update the branch.",
             "User: admin",
             "Contact an administrator to fix the permissions");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index 1ca019e..7598062 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -32,12 +32,12 @@
   public boolean runAs;
   public boolean runGC;
   public boolean streamEvents;
+  public boolean viewAccess;
   public boolean viewAllAccounts;
   public boolean viewCaches;
   public boolean viewConnections;
   public boolean viewPlugins;
   public boolean viewQueue;
-  public boolean viewAccess;
 
   static class QueryLimit {
     short min;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index ac82a78..b6033d4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -127,7 +127,7 @@
   }
 
   @Test
-  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
+  public void getExternalIdsOfOtherUserNotAllowed() {
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(
@@ -510,7 +510,7 @@
   }
 
   @Test
-  public void checkConsistencyNotAllowed() throws Exception {
+  public void checkConsistencyNotAllowed() {
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -882,7 +882,8 @@
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
       extIdNotes.insert(extId);
       extIdNotes.commit(update);
-      extIdNotes.updateCaches();
+      externalIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          extIdNotes, ImmutableList.of());
     }
   }
 
@@ -909,7 +910,8 @@
       metaDataUpdate.getCommitBuilder().setAuthor(admin.newIdent());
       metaDataUpdate.getCommitBuilder().setCommitter(admin.newIdent());
       extIdNotes.commit(metaDataUpdate);
-      extIdNotes.updateCaches();
+      externalIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          extIdNotes, ImmutableList.of());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/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/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/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index ad3a3c1..d93d3f7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -11,6 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -21,12 +22,13 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
-import static com.google.gerrit.server.restapi.change.DeleteChangeMessage.createNewChangeMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.util.RawParseUtils.decode;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -45,10 +47,12 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.inject.Inject;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
@@ -145,6 +149,29 @@
   }
 
   @Test
+  public void getChangeMessagesWithTemplate() throws Exception {
+    String changeId = createChange().getChangeId();
+    String messageTemplate = "Review by " + ChangeMessagesUtil.getAccountTemplate(admin.id());
+    postMessage(changeId, messageTemplate);
+    assertMessage(
+        messageTemplate,
+        Iterables.getLast(gApi.changes().id(changeId).get(MESSAGES).messages).message);
+
+    Collection<ChangeMessageInfo> listMessages = gApi.changes().id(changeId).messages();
+    assertThat(listMessages).hasSize(2);
+    ChangeMessageInfo changeMessageApi = Iterables.getLast(gApi.changes().id(changeId).messages());
+    assertMessage("Review by " + admin.getNameEmail(), changeMessageApi.message);
+    assertMessage(
+        "Review by " + admin.getNameEmail(),
+        gApi.changes().id(changeId).message(changeMessageApi.id).get().message);
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput("message deleted");
+    assertThat(gApi.changes().id(changeId).message(changeMessageApi.id).delete(input).message)
+        .isEqualTo(
+            String.format(
+                "Change message removed by: %s\nReason: message deleted", admin.getNameEmail()));
+  }
+
+  @Test
   public void deleteCannotBeAppliedWithoutAdministrateServerCapability() throws Exception {
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     requestScopeOperations.setApiUser(user.id());
@@ -278,10 +305,18 @@
     ChangeMessageInfo info = gApi.changes().id(changeNum).message(id).delete(input);
 
     // Verify the return change message info is as expect.
-    assertThat(info.message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), reason));
+    String expectedMessage = "Change message removed by: " + deletedBy.getNameEmail();
+    if (!Strings.isNullOrEmpty(reason)) {
+      expectedMessage = expectedMessage + "\nReason: " + reason;
+    }
+    assertThat(info.message).isEqualTo(expectedMessage);
     List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
     assertMessagesAfterDeletion(
-        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, deletedBy, reason);
+        messagesBeforeDeletion,
+        messagesAfterDeletion,
+        deletedMessageIndex,
+        deletedBy,
+        expectedMessage);
     assertCommentsAfterDeletion(changeNum, commentsBefore);
 
     // Verify change index is updated after deletion.
@@ -297,7 +332,7 @@
       List<ChangeMessageInfo> messagesAfterDeletion,
       int deletedMessageIndex,
       TestAccount deletedBy,
-      String deleteReason) {
+      String expectedDeleteMessage) {
     assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
         .that(messagesAfterDeletion)
         .hasSize(messagesBeforeDeletion.size());
@@ -317,8 +352,7 @@
       assertThat(after._revisionNumber).isEqualTo(before._revisionNumber);
 
       if (i == deletedMessageIndex) {
-        assertThat(after.message)
-            .isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+        assertThat(after.message).isEqualTo(expectedDeleteMessage);
       } else {
         assertThat(after.message).isEqualTo(before.message);
       }
@@ -381,7 +415,12 @@
                 rawAfter,
                 rangeAfter.get().changeMessageStart(),
                 rangeAfter.get().changeMessageEnd() + 1);
-        assertThat(message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+        String expectedMessageTemplate =
+            "Change message removed by: " + ChangeMessagesUtil.getAccountTemplate(deletedBy.id());
+        if (!Strings.isNullOrEmpty(deleteReason)) {
+          expectedMessageTemplate = expectedMessageTemplate + "\nReason: " + deleteReason;
+        }
+        assertThat(message).isEqualTo(expectedMessageTemplate);
       } else {
         assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/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/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 0c2c3a1..b4eb692 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
@@ -99,7 +100,10 @@
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            String.format(
+                "Removed Code-Review+1 by %s\n", ChangeMessagesUtil.getAccountTemplate(user.id())));
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index e94b660..6ed0bf8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -91,7 +92,8 @@
     expectedMessage.append("Change destination moved from master to moveTest");
     expectedMessage.append("\n\n");
     expectedMessage.append(moveMessage);
-    assertThat(r.getChange().messages().get(1).getMessage()).isEqualTo(expectedMessage.toString());
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
+        .isEqualTo(expectedMessage.toString());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index ffde622..ed6254a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -38,7 +38,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
@@ -502,7 +502,7 @@
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = foo2.id().toString();
     reviewerInput.state = ReviewerState.CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -525,7 +525,7 @@
 
     assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = foo2.id().toString();
     reviewerInput.state = ReviewerState.REVIEWER;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 4738f64..ea87922 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -214,4 +214,12 @@
     ServerInfo i = gApi.config().server().getInfo();
     assertThat(i.change.mergeabilityComputationBehavior).isEqualTo("NEVER");
   }
+
+  @Test
+  @GerritConfig(name = "download.scheme", value = "fooBar")
+  @GerritConfig(name = "download.command", value = "fooBar")
+  public void misconfiguredDownloadCommands() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.download.schemes).isEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index ff4f203..b99c624 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -11,1004 +11,73 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.ConfigSubject.assertThat;
-import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.ExtensionRegistry;
-import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.webui.FileHistoryWebLink;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.schema.GrantRevertPermission;
+import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
-import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
 import org.junit.Test;
 
 public class AccessIT extends AbstractDaemonTest {
-
-  private static final String REFS_ALL = Constants.R_REFS + "*";
-  private static final String REFS_HEADS = Constants.R_HEADS + "*";
-  private static final String REFS_META_VERSION = "refs/meta/version";
-  private static final String REFS_DRAFTS = "refs/draft-comments/*";
-  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
-
   @Inject private ProjectOperations projectOperations;
-  @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private ExtensionRegistry extensionRegistry;
-  @Inject private GrantRevertPermission grantRevertPermission;
 
-  private Project.NameKey newProjectName;
-
-  @Before
-  public void setUp() throws Exception {
-    newProjectName = projectOperations.newProject().create();
+  @Test
+  public void listAccessWithoutSpecifyingProject() throws Exception {
+    RestResponse r = adminRestSession.get("/access/");
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject).isEmpty();
   }
 
   @Test
-  public void grantRevertPermission() throws Exception {
-    String ref = "refs/*";
-    String groupId = "global:Registered-Users";
-
-    grantRevertPermission.execute(newProjectName);
-
-    ProjectAccessInfo info = pApi().access();
-    assertThat(info.local.containsKey(ref)).isTrue();
-    AccessSectionInfo accessSectionInfo = info.local.get(ref);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
-    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
-    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
-    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
-    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  public void listAccessWithoutSpecifyingAnEmptyProjectName() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?p=");
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject).isEmpty();
   }
 
   @Test
-  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
-    String refsHeads = "refs/heads/*";
-    String refsStar = "refs/*";
-    String groupId = "global:Registered-Users";
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.upsertAccessSection(
-          AccessSection.HEADS,
-          heads -> {
-            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-          });
-      md.getCommitBuilder().setAuthor(admin.newIdent());
-      md.getCommitBuilder().setCommitter(admin.newIdent());
-      md.setMessage("Add revert permission for all registered users\n");
-
-      projectConfig.commit(md);
-    }
-    grantRevertPermission.execute(newProjectName);
-
-    ProjectAccessInfo info = pApi().access();
-
-    // Revert permission is removed on refs/heads/*.
-    assertThat(info.local.containsKey(refsHeads)).isTrue();
-    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
-
-    // new permission is added on refs/* with Registered-Users.
-    assertThat(info.local.containsKey(refsStar)).isTrue();
-    accessSectionInfo = info.local.get(refsStar);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
-    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
-    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
-    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
-    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  public void listAccessForNonExistingProject() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?project=non-existing");
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo("non-existing");
   }
 
   @Test
-  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.upsertAccessSection(
-          AccessSection.HEADS,
-          heads -> {
-            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-            grant(projectConfig, heads, Permission.REVERT, otherGroup);
-          });
-      md.getCommitBuilder().setAuthor(admin.newIdent());
-      md.getCommitBuilder().setCommitter(admin.newIdent());
-      md.setMessage("Add revert permission for all registered users\n");
-
-      projectConfig.commit(md);
-    }
-    projectCache.evict(newProjectName);
-    ProjectAccessInfo expected = pApi().access();
-
-    grantRevertPermission.execute(newProjectName);
-    projectCache.evict(newProjectName);
-    ProjectAccessInfo actual = pApi().access();
-    // Permissions don't change
-    assertThat(expected.local).isEqualTo(actual.local);
-  }
-
-  @Test
-  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
-    grantRevertPermission.execute(newProjectName);
-    grantRevertPermission.execute(newProjectName);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
-
-      Permission permission = all.getPermission(Permission.REVERT);
-      assertThat(permission.getRules()).hasSize(1);
-    }
-  }
-
-  @Test
-  public void getDefaultInheritance() throws Exception {
-    String inheritedName = pApi().access().inheritsFrom.name;
-    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
-  }
-
-  private Registration newFileHistoryWebLink() {
-    FileHistoryWebLink weblink =
-        new FileHistoryWebLink() {
-          @Override
-          public WebLinkInfo getFileHistoryWebLink(
-              String projectName, String revision, String fileName) {
-            return new WebLinkInfo(
-                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
-          }
-        };
-    return extensionRegistry.newRegistration().add(weblink);
-  }
-
-  @Test
-  public void webLink() throws Exception {
-    try (Registration registration = newFileHistoryWebLink()) {
-      ProjectAccessInfo info = pApi().access();
-      assertThat(info.configWebLinks).hasSize(1);
-      assertThat(info.configWebLinks.get(0).url)
-          .isEqualTo("http://view/" + newProjectName + "/project.config");
-    }
-  }
-
-  @Test
-  public void webLinkNoRefsMetaConfig() throws Exception {
-    try (Repository repo = repoManager.openRepository(newProjectName);
-        Registration registration = newFileHistoryWebLink()) {
-      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
-      u.setForceUpdate(true);
-      assertThat(u.delete()).isEqualTo(Result.FORCED);
-
-      // This should not crash.
-      pApi().access();
-    }
-  }
-
-  @Test
-  public void addAccessSection() throws Exception {
-    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-
-    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(
-        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
-  }
-
-  @Test
-  public void addAccessSectionForPluginPermission() throws Exception {
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                new PluginProjectPermissionDefinition() {
-                  @Override
-                  public String getDescription() {
-                    return "A Plugin Project Permission";
-                  }
-                },
-                "fooPermission")) {
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-      PermissionInfo foo = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSectionInfo.permissions.put(
-          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
-
-      accessInput.add.put(REFS_HEADS, accessSectionInfo);
-      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
-      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
-
-      assertThat(pApi().access().local).isEqualTo(accessInput.add);
-    }
-  }
-
-  @Test
-  public void addAccessSectionWithInvalidPermission() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("Invalid Permission", push);
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
-  }
-
-  @Test
-  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("label-Invalid Permission", push);
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
-  }
-
-  @Test
-  public void createAccessChangeNop() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
-  }
-
-  @Test
-  public void createAccessChangeEmptyConfig() throws Exception {
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
-      ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(Result.FORCED);
-
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSection = newAccessSectionInfo();
-      PermissionInfo read = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
-      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSection.permissions.put(Permission.READ, read);
-      accessInput.add.put(REFS_HEADS, accessSection);
-
-      ChangeInfo out = pApi().accessChange(accessInput);
-      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    }
-  }
-
-  @Test
-  public void createAccessChange() throws Exception {
+  public void listAccessForNonVisibleProject() throws Exception {
     projectOperations
-        .project(newProjectName)
+        .project(project)
         .forUpdate()
-        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
-    // User can see the branch
-    requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
 
-    ProjectAccessInput accessInput = newProjectAccessInput();
-
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    // Deny read to registered users.
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    read.exclusive = true;
-    accessSection.permissions.put(Permission.READ, read);
-    accessInput.add.put(REFS_HEADS, accessSection);
-
-    requestScopeOperations.setApiUser(user.id());
-    ChangeInfo out = pApi().accessChange(accessInput);
-
-    assertThat(out.project).isEqualTo(newProjectName.get());
-    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
-    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(out.submitted).isNull();
-
-    requestScopeOperations.setApiUser(admin.id());
-
-    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
-    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
-
-    ReviewInput reviewIn = new ReviewInput();
-    reviewIn.label("Code-Review", (short) 2);
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // check that the change took effect.
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
-
-    // Restore.
-    accessInput.add.clear();
-    accessInput.remove.put(REFS_HEADS, accessSection);
-    requestScopeOperations.setApiUser(user.id());
-
-    requestScopeOperations.setApiUser(admin.id());
-    out = pApi().accessChange(accessInput);
-
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // Now it works again.
-    requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
+    RestResponse r = userRestSession.get("/access/?project=" + project.get());
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo(project.get());
   }
 
   @Test
-  public void removePermission() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    accessSectionToRemove.permissions.put(
-        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void removePermissionRule() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission rule
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput
-        .add
-        .get(REFS_HEADS)
-        .permissions
-        .get(Permission.LABEL + LabelId.CODE_REVIEW)
-        .rules
-        .remove(SystemGroupBackend.REGISTERED_USERS.get());
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission rules
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void getPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
-    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
-    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
-  }
-
-  @Test
-  public void setPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
-    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
-    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Create a change to apply
-    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
-    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
-  }
-
-  @Test
-  public void permissionsGroupMap() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo read = newPermissionInfo();
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    accessInput.add.put(REFS_ALL, accessSection);
-    ProjectAccessInfo result = pApi().access(accessInput);
-    assertThatMap(result.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    // Check the name, which is what the UI cares about; exhaustive
-    // coverage of GroupInfo should be in groups REST API tests.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
-        .isEqualTo("Project Owners");
-    // Strip the ID, since it is in the key.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
-
-    // Get call returns groups too.
-    ProjectAccessInfo loggedInResult = pApi().access();
-    assertThatMap(loggedInResult.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
-    assertThat(owners.name).isEqualTo("Project Owners");
-    assertThat(owners.id).isNull();
-    assertThat(owners.members).isNull();
-    assertThat(owners.includes).isNull();
-
-    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
-    requestScopeOperations.setApiUserAnonymous();
-    ProjectAccessInfo anonResult = pApi().access();
-    assertThatMap(anonResult.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-  }
-
-  @Test
-  public void updateParentAsUser() throws Exception {
-    // Create child
-    String newParentProjectName = projectOperations.newProject().create().get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
-    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
-  }
-
-  @Test
-  public void updateParentAsAdministrator() throws Exception {
-    // Create parent
-    String newParentProjectName = projectOperations.newProject().create().get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    pApi().access(accessInput);
-
-    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
-  }
-
-  @Test
-  public void addGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void addGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedAccessSectionInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void addPluginGlobalCapability() throws Exception {
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                new CapabilityDefinition() {
-                  @Override
-                  public String getDescription() {
-                    return "A Plugin Global Capability";
-                  }
-                },
-                "fooCapability")) {
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-      PermissionInfo foo = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
-
-      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-      ProjectAccessInfo updatedAccessSectionInfo =
-          gApi.projects().name(allProjects.get()).access(accessInput);
-      assertThatMap(
-              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-          .keys()
-          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-    }
-  }
-
-  @Test
-  public void addPermissionAsGlobalCapability() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put(Permission.PUSH, push);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
-  }
-
-  @Test
-  public void addInvalidGlobalCapability() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).access(accessInput));
-    assertThat(ex)
-        .hasMessageThat()
-        .isEqualTo("Unknown global capability: Invalid Global Capability");
-  }
-
-  @Test
-  public void addGlobalCapabilityForNonRootProject() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-  }
-
-  @Test
-  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroupUuid().get(), null);
-    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    assertThrows(
-        BadRequestException.class,
-        () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroupUuid().get(), null);
-    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
-
-    // Add and validate first as removing existing privileges such as
-    // administrateServer would break upcoming tests
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedProjectAccessInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-
-    // Remove
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsNoneIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void unknownPermissionRemainsUnchanged() throws Exception {
-    String access = "access";
-    String unknownPermission = "unknownPermission";
-    String registeredUsers = "group Registered Users";
-    String refsFor = "refs/for/*";
-    // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
-
-    // Fetch permission ref
-    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
-    allProjectsRepo.reset("cfg");
-
-    // Load current permissions
-    String config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-
-    // Append and push unknown permission
-    Config cfg = new Config();
-    cfg.fromText(config);
-    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
-    config = cfg.toText();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
-    push.to(RefNames.REFS_CONFIG).assertOkStatus();
-
-    // Verify that unknownPermission is present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
-
-    // Make permission change through API
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-    accessInput.add.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-    accessInput.add.clear();
-    accessInput.remove.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Verify that unknownPermission is still present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
-  }
-
-  @Test
-  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = project.get();
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allUsers.get()).access(accessInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(allUsers.get() + " must inherit from " + allProjects.get());
-  }
-
-  @Test
-  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
-    // Grant CREATE_GROUP to Registered Users
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-    PermissionInfo createGroup = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
-    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThatMap(rules).values().containsExactly(pri);
-
-    // Revoke the permission
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
-  }
-
-  @Test
-  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
-      throws Exception {
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-
-    // Grant CREATE_GROUP to Registered Users
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-    PermissionInfo createGroup = newPermissionInfo();
-    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Grant CREATE_GROUP to Administrators
-    accessInput = newProjectAccessInput();
-    accessSection = newAccessSectionInfo();
-    createGroup = newPermissionInfo();
-    createGroup.rules.put(adminGroupUuid().get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
-    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThatMap(rules)
-        .keys()
-        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
-    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
-    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
-  }
-
-  @Test
-  public void addAccessSectionForInvalidRef() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
-    String invalidRef = Constants.R_HEADS + "stable_*";
-    accessInput.add.put(invalidRef, accessSectionInfo);
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
-  }
-
-  @Test
-  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
-    String invalidRef = Constants.R_HEADS + "stable_*";
-    accessInput.add.put(invalidRef, accessSectionInfo);
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
-    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
-  }
-
-  private ProjectApi pApi() throws Exception {
-    return gApi.projects().name(newProjectName.get());
-  }
-
-  private ProjectAccessInput newProjectAccessInput() {
-    ProjectAccessInput p = new ProjectAccessInput();
-    p.add = new HashMap<>();
-    p.remove = new HashMap<>();
-    return p;
-  }
-
-  private PermissionInfo newPermissionInfo() {
-    PermissionInfo p = new PermissionInfo(null, null);
-    p.rules = new HashMap<>();
-    return p;
-  }
-
-  private AccessSectionInfo newAccessSectionInfo() {
-    AccessSectionInfo a = new AccessSectionInfo();
-    a.permissions = new HashMap<>();
-    return a;
-  }
-
-  private AccessSectionInfo createDefaultAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    pri.max = 1;
-    pri.min = -1;
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo email = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createAccessSectionInfoDenyAll() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    return accessSection;
+  public void listAccess() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?project=" + project.get());
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(project.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index f267513..4a8c376 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -41,12 +41,12 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -273,7 +273,7 @@
   }
 
   /*
-   * AddReviewerSender tests.
+   * ModifyReviewerSender tests (only for additions).
    */
 
   private void addReviewerToReviewableChange(Adder adder) throws Exception {
@@ -421,7 +421,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
     // TODO(dborowitz): In theory this should match the batch case, but we don't currently pass
-    // enough info into AddReviewersEmail#emailReviewers to distinguish the reviewStarted case.
+    // enough info into ModifyReviewersEmail#emailReviewers to distinguish the reviewStarted case.
     // Complicating the emailReviewers arguments is not the answer; this needs to be rewritten.
     // Tolerate the difference for now.
     assertThat(sender).didNotSend();
@@ -582,7 +582,7 @@
 
   private Adder singly(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = reviewer;
       in.state = reviewerState;
       if (notify != null) {
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 5679c41..85c0212 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
@@ -28,7 +30,9 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -44,6 +48,7 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
@@ -61,6 +66,7 @@
   @Inject private MailProcessor mailProcessor;
   @Inject private AccountOperations accountOperations;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private ProjectOperations projectOperations;
 
   private static final CommentValidator mockCommentValidator = mock(CommentValidator.class);
 
@@ -276,6 +282,133 @@
   }
 
   @Test
+  public void sendNotificationOnProjectNotFound() throws Exception {
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(TimeUtil.now(), ZoneId.of("UTC")));
+
+    String changeUrl = canonicalWebUrl.get() + "c/non-existing-project/+/123";
+
+    // Build Message
+    String txt = newPlaintextBody(changeUrl + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(123, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnProjectNotVisible() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Block read permissions on the project.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnChangeNotFound() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Delete the change so that it's not found.
+    gApi.changes().id(changeId).delete();
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnChangeNotVisible() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Make change private so that it's no visible to user.
+    gApi.changes().id(changeId).setPrivate(true);
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
   public void validateChangeMessage_rejected() throws Exception {
     String changeId = createChangeWithReview();
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6aa5878..6b34cca 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -43,8 +43,8 @@
 import com.google.gerrit.entities.CachedProjectConfig;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -169,7 +169,7 @@
     try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
       saveLabelConfig(P.toBuilder().setFunction(ANY_WITH_BLOCK));
       PushOneCommit.Result r = createChange();
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
index 2692584..c9c3bbb 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
@@ -76,8 +76,13 @@
 
   @Test
   public void pushWithAvailableTokens() throws Exception {
+    // The push creates a pack that contains 325 bytes of uncompressed data.
+    // The data in the push contains sha and timestamps which are different on each test run.
+    // Due to it, the push's pack size varies after data compression and lead to a flaky tests
+    // if the amount of availableTokens doesn't cover all possible sizes. To avoid flakiness, we
+    // set availableTokens value large enough to cover all possible pack sizes.
     when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
-        .thenReturn(singletonAggregation(ok(277L)));
+        .thenReturn(singletonAggregation(ok(512L)));
     when(quotaBackendWithResource.requestTokens(eq(REPOSITORY_SIZE_GROUP), anyLong()))
         .thenReturn(singletonAggregation(ok()));
     when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index 78960bb..cf316c7 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.data.ChangeAttribute;
@@ -84,7 +84,7 @@
   @Test
   public void allReviewersOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 6ca9871..6c4d1e4 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index b185558..f612d0f 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -19,12 +19,15 @@
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -36,14 +39,14 @@
   @Test
   public void allValuesConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
-            PatchSet.id(Change.id(34), 13));
-    changeMessage.setMessage("This is a change message.");
-    changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(Account.id(10003));
+            PatchSet.id(Change.id(34), 13),
+            "This is a change message.",
+            Account.id(10003),
+            "An arbitrary tag.");
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -69,7 +72,7 @@
   @Test
   public void mainValuesConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
@@ -97,7 +100,7 @@
   @Test
   public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"), Account.id(63), null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
@@ -118,7 +121,8 @@
     // writtenOn may not be null according to the column definition but it's optional for the
     // protobuf definition. -> assume as optional and hence test null
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -135,14 +139,14 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
-            PatchSet.id(Change.id(34), 13));
-    changeMessage.setMessage("This is a change message.");
-    changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(Account.id(10003));
+            PatchSet.id(Change.id(34), 13),
+            "This is a change message.",
+            Account.id(10003),
+            "An arbitrary tag.");
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -150,9 +154,31 @@
   }
 
   @Test
+  public void messageTemplateConvertedToProtoAndParsedBack() {
+    ChangeMessage changeMessage =
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13),
+            String.format(
+                "This is a change message by %s and includes %s ",
+                ChangeMessagesUtil.getAccountTemplate(Account.id(10001)),
+                ChangeMessagesUtil.getAccountTemplate(Account.id(10002))),
+            Account.id(10003),
+            "An arbitrary tag.");
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage.getAccountsInMessage())
+        .containsExactly(Account.id(10001), Account.id(10002));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
   public void mainValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
@@ -166,7 +192,8 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -183,6 +210,8 @@
                 .put("author", Account.Id.class)
                 .put("writtenOn", Timestamp.class)
                 .put("message", String.class)
+                // accountsInMessage are parsed from message template and are not serialized.
+                .put("accountsInMessage", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType())
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
                 .put("realAuthor", Account.Id.class)
diff --git a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
index 5e8c7b6..543428a 100644
--- a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
+++ b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
@@ -19,8 +19,10 @@
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAR;
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAZ;
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.FOO;
+import static org.junit.Assert.fail;
 
 import com.google.common.math.IntMath;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.util.EnumSet;
 import org.junit.Test;
 
@@ -43,6 +45,17 @@
   }
 
   @Test
+  public void fromHexString() {
+    try {
+      // TODO(hanwen): move GerritJUnit.assertThrows to a place that doesn't depend on everything.
+      ListOption.fromHexString(MyOption.class, "xyz");
+      fail("must throw");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).contains("32-bit integer");
+    }
+  }
+
+  @Test
   public void fromBits() {
     assertThat(IntMath.pow(2, BAZ.getValue())).isEqualTo(131072);
     assertThat(ListOption.fromBits(MyOption.class, 0)).isEmpty();
diff --git a/javatests/com/google/gerrit/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/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 5f375ad..feff89c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -721,7 +721,7 @@
   @Test
   public void serializeChangeMessages() throws Exception {
     ChangeMessage m1 =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(ID, "uuid1"),
             Account.id(1000),
             new Timestamp(1212L),
@@ -731,7 +731,7 @@
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(ID, "uuid2"),
             Account.id(2000),
             new Timestamp(3434L),
@@ -1007,6 +1007,8 @@
                 .put("author", Account.Id.class)
                 .put("writtenOn", Timestamp.class)
                 .put("message", String.class)
+                // accountsInMessage are parsed from message template and are not serialized.
+                .put("accountsInMessage", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType())
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
                 .put("realAuthor", Account.Id.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index de49cdf..fa37704 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.AssigneeStatusUpdate;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
@@ -1602,6 +1603,7 @@
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
@@ -1621,10 +1623,32 @@
                 + "Testing paragraph 2\n"
                 + "\n"
                 + "Testing paragraph 3");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
+  public void changeMessageWithTemplate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    String messageTemplate =
+        String.format(
+            "Change update by %s, also includes %s",
+            ChangeMessagesUtil.getAccountTemplate(changeOwner.getAccountId()),
+            ChangeMessagesUtil.getAccountTemplate(otherUser.getAccountId()));
+    update.setChangeMessage(messageTemplate);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    ChangeMessage cm = Iterables.getOnlyElement(notes.getChangeMessages());
+    assertThat(cm.getMessage()).isEqualTo(messageTemplate);
+
+    assertThat(cm.getAccountsInMessage())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+  }
+
+  @Test
   public void changeMessagesMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -1646,11 +1670,13 @@
     ChangeMessage cm1 = notes.getChangeMessages().get(0);
     assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
     assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
 
     ChangeMessage cm2 = notes.getChangeMessages().get(1);
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
     assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
+
     assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index aecb5d3..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/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index b3e0c56..44c3cef 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -125,9 +125,8 @@
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
     ChangeMessage cm =
-        new ChangeMessage(key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null);
-    cm.setMessage(message);
-    cm.setTag(tag);
+        ChangeMessage.create(
+            key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null, message, null, tag);
     return cm;
   }
 
diff --git a/lib/LICENSE-CC-BY3.0-unported b/lib/LICENSE-CC-BY3.0-unported
deleted file mode 100644
index d2f2550..0000000
--- a/lib/LICENSE-CC-BY3.0-unported
+++ /dev/null
@@ -1,333 +0,0 @@
-link:http://creativecommons.org/licenses/by/3.0/[CC-BY 3.0]
-
-THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
-CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE").  THE WORK IS
-PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW.  ANY USE OF THE
-WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
-PROHIBITED.
-
-BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND
-AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.  TO THE EXTENT THIS
-LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU
-THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH
-TERMS AND CONDITIONS.
-
-1.  Definitions
-
-  a.  "Adaptation" means a work based upon the Work, or upon the Work
-      and other pre-existing works, such as a translation, adaptation,
-      derivative work, arrangement of music or other alterations of a
-      literary or artistic work, or phonogram or performance and
-      includes cinematographic adaptations or any other form in which
-      the Work may be recast, transformed, or adapted including in any
-      form recognizably derived from the original, except that a work
-      that constitutes a Collection will not be considered an
-      Adaptation for the purpose of this License.  For the avoidance
-      of doubt, where the Work is a musical work, performance or
-      phonogram, the synchronization of the Work in timed-relation
-      with a moving image ("synching") will be considered an
-      Adaptation for the purpose of this License.
-
-  b.  "Collection" means a collection of literary or artistic works,
-      such as encyclopedias and anthologies, or performances,
-      phonograms or broadcasts, or other works or subject matter other
-      than works listed in Section 1(f) below, which, by reason of the
-      selection and arrangement of their contents, constitute
-      intellectual creations, in which the Work is included in its
-      entirety in unmodified form along with one or more other
-      contributions, each constituting separate and independent works
-      in themselves, which together are assembled into a collective
-      whole.  A work that constitutes a Collection will not be
-      considered an Adaptation (as defined above) for the purposes of
-      this License.
-
-  c.  "Distribute" means to make available to the public the original
-      and copies of the Work or Adaptation, as appropriate, through
-      sale or other transfer of ownership.
-
-  d.  "Licensor" means the individual, individuals, entity or entities
-      that offer(s) the Work under the terms of this License.
-
-  e.  "Original Author" means, in the case of a literary or artistic
-      work, the individual, individuals, entity or entities who
-      created the Work or if no individual or entity can be
-      identified, the publisher; and in addition (i) in the case of a
-      performance the actors, singers, musicians, dancers, and other
-      persons who act, sing, deliver, declaim, play in, interpret or
-      otherwise perform literary or artistic works or expressions of
-      folklore; (ii) in the case of a phonogram the producer being the
-      person or legal entity who first fixes the sounds of a
-      performance or other sounds; and, (iii) in the case of
-      broadcasts, the organization that transmits the broadcast.
-
-  f.  "Work" means the literary and/or artistic work offered under the
-      terms of this License including without limitation any
-      production in the literary, scientific and artistic domain,
-      whatever may be the mode or form of its expression including
-      digital form, such as a book, pamphlet and other writing; a
-      lecture, address, sermon or other work of the same nature; a
-      dramatic or dramatico-musical work; a choreographic work or
-      entertainment in dumb show; a musical composition with or
-      without words; a cinematographic work to which are assimilated
-      works expressed by a process analogous to cinematography; a work
-      of drawing, painting, architecture, sculpture, engraving or
-      lithography; a photographic work to which are assimilated works
-      expressed by a process analogous to photography; a work of
-      applied art; an illustration, map, plan, sketch or
-      three-dimensional work relative to geography, topography,
-      architecture or science; a performance; a broadcast; a
-      phonogram; a compilation of data to the extent it is protected
-      as a copyrightable work; or a work performed by a variety or
-      circus performer to the extent it is not otherwise considered a
-      literary or artistic work.
-
-  g.  "You" means an individual or entity exercising rights under this
-      License who has not previously violated the terms of this
-      License with respect to the Work, or who has received express
-      permission from the Licensor to exercise rights under this
-      License despite a previous violation.
-
-  h.  "Publicly Perform" means to perform public recitations of the
-      Work and to communicate to the public those public recitations,
-      by any means or process, including by wire or wireless means or
-      public digital performances; to make available to the public
-      Works in such a way that members of the public may access these
-      Works from a place and at a place individually chosen by them;
-      to perform the Work to the public by any means or process and
-      the communication to the public of the performances of the Work,
-      including by public digital performance; to broadcast and
-      rebroadcast the Work by any means including signs, sounds or
-      images.
-
-  i.  "Reproduce" means to make copies of the Work by any means
-      including without limitation by sound or visual recordings and
-      the right of fixation and reproducing fixations of the Work,
-      including storage of a protected performance or phonogram in
-      digital form or other electronic medium.
-
-2.  Fair Dealing Rights.  Nothing in this License is intended to
-    reduce, limit, or restrict any uses free from copyright or rights
-    arising from limitations or exceptions that are provided for in
-    connection with the copyright protection under copyright law or
-    other applicable laws.
-
-3.  License Grant.  Subject to the terms and conditions of this
-    License, Licensor hereby grants You a worldwide, royalty-free,
-    non-exclusive, perpetual (for the duration of the applicable
-    copyright) license to exercise the rights in the Work as stated
-    below:
-
-  a.  to Reproduce the Work, to incorporate the Work into one or more
-      Collections, and to Reproduce the Work as incorporated in the
-      Collections;
-
-  b.  to create and Reproduce Adaptations provided that any such
-      Adaptation, including any translation in any medium, takes
-      reasonable steps to clearly label, demarcate or otherwise
-      identify that changes were made to the original Work.  For
-      example, a translation could be marked "The original work was
-      translated from English to Spanish," or a modification could
-      indicate "The original work has been modified.";
-
-  c.  to Distribute and Publicly Perform the Work including as
-      incorporated in Collections; and,
-
-  d.  to Distribute and Publicly Perform Adaptations.
-
-  e.  For the avoidance of doubt:
-
-    i.   Non-waivable Compulsory License Schemes.  In those
-	     jurisdictions in which the right to collect royalties
-	     through any statutory or compulsory licensing scheme
-	     cannot be waived, the Licensor reserves the exclusive
-	     right to collect such royalties for any exercise by You
-	     of the rights granted under this License;
-
-    ii.  Waivable Compulsory License Schemes.  In those jurisdictions
-	     in which the right to collect royalties through any
-	     statutory or compulsory licensing scheme can be waived,
-	     the Licensor waives the exclusive right to collect such
-	     royalties for any exercise by You of the rights granted
-	     under this License; and,
-
-    iii. Voluntary License Schemes.  The Licensor waives the right to
-	     collect royalties, whether individually or, in the event
-	     that the Licensor is a member of a collecting society
-	     that administers voluntary licensing schemes, via that
-	     society, from any exercise by You of the rights granted
-	     under this License.
-
-The above rights may be exercised in all media and formats whether now
-known or hereafter devised.  The above rights include the right to
-make such modifications as are technically necessary to exercise the
-rights in other media and formats.  Subject to Section 8(f), all
-rights not expressly granted by Licensor are hereby reserved.
-
-4.  Restrictions.  The license granted in Section 3 above is expressly
-    made subject to and limited by the following restrictions:
-
-  a.  You may Distribute or Publicly Perform the Work only under the
-      terms of this License.  You must include a copy of, or the
-      Uniform Resource Identifier (URI) for, this License with every
-      copy of the Work You Distribute or Publicly Perform.  You may
-      not offer or impose any terms on the Work that restrict the
-      terms of this License or the ability of the recipient of the
-      Work to exercise the rights granted to that recipient under the
-      terms of the License.  You may not sublicense the Work.  You
-      must keep intact all notices that refer to this License and to
-      the disclaimer of warranties with every copy of the Work You
-      Distribute or Publicly Perform.  When You Distribute or Publicly
-      Perform the Work, You may not impose any effective technological
-      measures on the Work that restrict the ability of a recipient of
-      the Work from You to exercise the rights granted to that
-      recipient under the terms of the License.  This Section 4(a)
-      applies to the Work as incorporated in a Collection, but this
-      does not require the Collection apart from the Work itself to be
-      made subject to the terms of this License.  If You create a
-      Collection, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Collection any credit as
-      required by Section 4(b), as requested.  If You create an
-      Adaptation, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Adaptation any credit as
-      required by Section 4(b), as requested.
-
-  b.  If You Distribute, or Publicly Perform the Work or any
-      Adaptations or Collections, You must, unless a request has been
-      made pursuant to Section 4(a), keep intact all copyright notices
-      for the Work and provide, reasonable to the medium or means You
-      are utilizing: (i) the name of the Original Author (or
-      pseudonym, if applicable) if supplied, and/or if the Original
-      Author and/or Licensor designate another party or parties (e.g.,
-      a sponsor institute, publishing entity, journal) for attribution
-      ("Attribution Parties") in Licensor's copyright notice, terms of
-      service or by other reasonable means, the name of such party or
-      parties; (ii) the title of the Work if supplied; (iii) to the
-      extent reasonably practicable, the URI, if any, that Licensor
-      specifies to be associated with the Work, unless such URI does
-      not refer to the copyright notice or licensing information for
-      the Work; and (iv) , consistent with Section 3(b), in the case
-      of an Adaptation, a credit identifying the use of the Work in
-      the Adaptation (e.g., "French translation of the Work by
-      Original Author," or "Screenplay based on original Work by
-      Original Author").  The credit required by this Section 4 (b)
-      may be implemented in any reasonable manner; provided, however,
-      that in the case of a Adaptation or Collection, at a minimum
-      such credit will appear, if a credit for all contributing
-      authors of the Adaptation or Collection appears, then as part of
-      these credits and in a manner at least as prominent as the
-      credits for the other contributing authors.  For the avoidance
-      of doubt, You may only use the credit required by this Section
-      for the purpose of attribution in the manner set out above and,
-      by exercising Your rights under this License, You may not
-      implicitly or explicitly assert or imply any connection with,
-      sponsorship or endorsement by the Original Author, Licensor
-      and/or Attribution Parties, as appropriate, of You or Your use
-      of the Work, without the separate, express prior written
-      permission of the Original Author, Licensor and/or Attribution
-      Parties.
-
-  c.  Except as otherwise agreed in writing by the Licensor or as may
-      be otherwise permitted by applicable law, if You Reproduce,
-      Distribute or Publicly Perform the Work either by itself or as
-      part of any Adaptations or Collections, You must not distort,
-      mutilate, modify or take other derogatory action in relation to
-      the Work which would be prejudicial to the Original Author's
-      honor or reputation.  Licensor agrees that in those
-      jurisdictions (e.g.  Japan), in which any exercise of the right
-      granted in Section 3(b) of this License (the right to make
-      Adaptations) would be deemed to be a distortion, mutilation,
-      modification or other derogatory action prejudicial to the
-      Original Author's honor and reputation, the Licensor will waive
-      or not assert, as appropriate, this Section, to the fullest
-      extent permitted by the applicable national law, to enable You
-      to reasonably exercise Your right under Section 3(b) of this
-      License (right to make Adaptations) but not otherwise.
-
-5.  Representations, Warranties and Disclaimer
-
-UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING,
-LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR
-WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED,
-STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF
-TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE,
-NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY,
-OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE.
-SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES,
-SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
-
-6.  Limitation on Liability.  EXCEPT TO THE EXTENT REQUIRED BY
-    APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY
-    LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
-    OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE
-    WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
-    DAMAGES.
-
-7.  Termination
-
-  a.  This License and the rights granted hereunder will terminate
-      automatically upon any breach by You of the terms of this
-      License.  Individuals or entities who have received Adaptations
-      or Collections from You under this License, however, will not
-      have their licenses terminated provided such individuals or
-      entities remain in full compliance with those licenses.
-      Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
-      this License.
-
-  b.  Subject to the above terms and conditions, the license granted
-      here is perpetual (for the duration of the applicable copyright
-      in the Work).  Notwithstanding the above, Licensor reserves the
-      right to release the Work under different license terms or to
-      stop distributing the Work at any time; provided, however that
-      any such election will not serve to withdraw this License (or
-      any other license that has been, or is required to be, granted
-      under the terms of this License), and this License will continue
-      in full force and effect unless terminated as stated above.
-
-8. Miscellaneous
-
-  a.  Each time You Distribute or Publicly Perform the Work or a
-      Collection, the Licensor offers to the recipient a license to
-      the Work on the same terms and conditions as the license granted
-      to You under this License.
-
-  b.  Each time You Distribute or Publicly Perform an Adaptation,
-      Licensor offers to the recipient a license to the original Work
-      on the same terms and conditions as the license granted to You
-      under this License.
-
-  c.  If any provision of this License is invalid or unenforceable
-      under applicable law, it shall not affect the validity or
-      enforceability of the remainder of the terms of this License,
-      and without further action by the parties to this agreement,
-      such provision shall be reformed to the minimum extent necessary
-      to make such provision valid and enforceable.
-
-  d.  No term or provision of this License shall be deemed waived and
-      no breach consented to unless such waiver or consent shall be in
-      writing and signed by the party to be charged with such waiver
-      or consent.
-
-  e.  This License constitutes the entire agreement between the
-      parties with respect to the Work licensed here.  There are no
-      understandings, agreements or representations with respect to
-      the Work not specified here.  Licensor shall not be bound by any
-      additional provisions that may appear in any communication from
-      You.  This License may not be modified without the mutual
-      written agreement of the Licensor and You.
-
-  f.  The rights granted under, and the subject matter referenced, in
-      this License were drafted utilizing the terminology of the Berne
-      Convention for the Protection of Literary and Artistic Works (as
-      amended on September 28, 1979), the Rome Convention of 1961, the
-      WIPO Copyright Treaty of 1996, the WIPO Performances and
-      Phonograms Treaty of 1996 and the Universal Copyright Convention
-      (as revised on July 24, 1971).  These rights and subject matter
-      take effect in the relevant jurisdiction in which the License
-      terms are sought to be enforced according to the corresponding
-      provisions of the implementation of those treaty provisions in
-      the applicable national law.  If the standard suite of rights
-      granted under applicable copyright law includes additional
-      rights not granted under this License, such additional rights
-      are deemed to be included in the License; this License is not
-      intended to restrict the license of any rights under applicable
-      law.
diff --git a/lib/LICENSE-OFL1.1 b/lib/LICENSE-OFL1.1
deleted file mode 100644
index 0754257..0000000
--- a/lib/LICENSE-OFL1.1
+++ /dev/null
@@ -1,93 +0,0 @@
-Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-
-This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/lib/LICENSE-ba-linkify b/lib/LICENSE-ba-linkify
deleted file mode 100644
index 93672f9..0000000
--- a/lib/LICENSE-ba-linkify
+++ /dev/null
@@ -1,22 +0,0 @@
-Copyright (c) 2009 "Cowboy" Ben Alman
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-codemirror-minified b/lib/LICENSE-codemirror-minified
deleted file mode 100644
index 89f23625..0000000
--- a/lib/LICENSE-codemirror-minified
+++ /dev/null
@@ -1,22 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/lib/LICENSE-es6-promise b/lib/LICENSE-es6-promise
deleted file mode 100644
index 954ec59..0000000
--- a/lib/LICENSE-es6-promise
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/lib/LICENSE-fetch b/lib/LICENSE-fetch
deleted file mode 100644
index 0e319d5..0000000
--- a/lib/LICENSE-fetch
+++ /dev/null
@@ -1,20 +0,0 @@
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-moment b/lib/LICENSE-moment
deleted file mode 100644
index 9ee5374..0000000
--- a/lib/LICENSE-moment
+++ /dev/null
@@ -1,22 +0,0 @@
-Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-page.js b/lib/LICENSE-page.js
deleted file mode 100644
index 78152a9..0000000
--- a/lib/LICENSE-page.js
+++ /dev/null
@@ -1,20 +0,0 @@
-(The MIT License)
-
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the 'Software'), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-polymer b/lib/LICENSE-polymer
deleted file mode 100644
index 322c5a8..0000000
--- a/lib/LICENSE-polymer
+++ /dev/null
@@ -1,27 +0,0 @@
-Copyright (c) 2014 The Polymer Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/LICENSE-promise-polyfill b/lib/LICENSE-promise-polyfill
deleted file mode 100644
index 6f7c0123..0000000
--- a/lib/LICENSE-promise-polyfill
+++ /dev/null
@@ -1,20 +0,0 @@
-Copyright (c) 2014 Taylor Hakes
-Copyright (c) 2014 Forbes Lindesay
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/lib/LICENSE-resemblejs b/lib/LICENSE-resemblejs
deleted file mode 100644
index b265c8a..0000000
--- a/lib/LICENSE-resemblejs
+++ /dev/null
@@ -1,18 +0,0 @@
-The MIT License (MIT) Copyright © 2013 Huddle
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the “Software”), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/LICENSE-shadycss b/lib/LICENSE-shadycss
deleted file mode 100644
index 0fe5c52..0000000
--- a/lib/LICENSE-shadycss
+++ /dev/null
@@ -1,20 +0,0 @@
-# License
-
-Everything in this repo is BSD style license unless otherwise specified.
-
-Copyright (c) 2015 The Polymer Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-* Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-* Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
diff --git a/lib/js/BUILD b/lib/js/BUILD
index 106aabb..82089bd 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -1,4 +1,4 @@
-load("//tools/bzl:js.bzl", "bower_component", "js_component")
+load("//tools/bzl:js.bzl", "js_component")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -22,15 +22,3 @@
     srcs = ["//lib/ba-linkify:ba-linkify.js"],
     license = "//lib:LICENSE-ba-linkify",
 )
-
-##TODO: remove after plugins migration to npm
-bower_component(
-    name = "codemirror-minified",
-    license = "//lib:LICENSE-codemirror-minified",
-)
-
-bower_component(
-    name = "resemblejs",
-    license = "//lib:LICENSE-resemblejs",
-)
-#End of removal
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
deleted file mode 100644
index 5a6a8c0..0000000
--- a/lib/js/npm.bzl
+++ /dev/null
@@ -1,11 +0,0 @@
-NPM_VERSIONS = {
-    "bower": "1.8.8",
-    "crisper": "2.0.2",
-    "polymer-bundler": "4.0.9",
-}
-
-NPM_SHA1S = {
-    "bower": "82544be34a33aeae7efb8bdf9905247b2cffa985",
-    "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "polymer-bundler": "c80c9815690d76656d1fa6a231481850b4fa3874",
-}
diff --git a/package.json b/package.json
index 320b196..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..13cefb7 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 93e61dc64debe42eab454e6c268f9c4ee22a78bc
+Subproject commit 13cefb724df786d254ecbc24261589ab473be267
diff --git a/polygerrit-ui/Polymer2.md b/polygerrit-ui/Polymer2.md
deleted file mode 100644
index e2a9124..0000000
--- a/polygerrit-ui/Polymer2.md
+++ /dev/null
@@ -1,19 +0,0 @@
-Note: Gerrit has moved to polymer 3 as of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
-
-The change is backward compatible, so no code change needed to support all plugins, but we would highly recommend to start moving to latest polymer 3 for all plugins, check out [Polymer3.md](./Polymer3.md) for more insights.
-
-## Polymer 2 upgrade
-
-Gerrit is updating to use polymer 2 from polymer 1 by following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade).
-
-Polymer 2 contains several breaking changes that may affect some of the UI features and plugins. One of the biggest change is to have the shadow DOM enabled. This will affect how you query elements inside of your component, how css style works within and across components, and several other usages.
-
-If you are owner of any plugins, please start following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade) to migrate your plugins to be polymer 2 ready.
-
-If you notice any issues or need help with anything, don't hesitate to report to us [here](https://bugs.chromium.org/p/gerrit/issues/list).
-
-
-### Related resources
-
-- [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade)
-- [Polymer Shadow DOM](https://polymer-library.polymer-project.org/2.0/docs/devguide/shadow-dom)
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
deleted file mode 100644
index 186f0f4..0000000
--- a/polygerrit-ui/Polymer3.md
+++ /dev/null
@@ -1,25 +0,0 @@
-## Gerrit in Polymer 3
-
-Gerrit has migrated to polymer 3 as of submitted of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
-
-## Polymer 3 vs Polymer 2
-
-The biggest difference between 2 and 3 is the changing of package management from bower to npm and also replaced the html imports with es6 imports so we no longer need templates in separate `html` files for polymer components.
-
-### How that impact plugins
-
-As of now, we still support all syntax in Polymer 2 and most from Polymer 1 with the [legacy layer](https://polymer-library.polymer-project.org/3.0/docs/devguide/legacy-elements). But we do plan to remove those in the future.
-
-So we recommend all plugin owners to start migrating to Polymer 3 for your plugins. You can refer more about polymer 3 from the related resources section.
-
-To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
-
-### Plugin dependencies
-
-Since most of Gerrit plugins are treated as sub modules and part of the Gerrit workspace when develop, dependencies of plugins are also defined and installed from Gerrit WORKSPACE, currently most of them are `bower_archives`. When moving to npm, if your plugin requires dependencies, you can have them added to your plugin's `package.json` and then link that file to `plugins/package.json` in gerrit.
-Then use `@plugins_npm//:node_modules` to make sure `rollup_bundle` knows the right place to look for. More examples from `image-diff` plugin, [change 271672](https://gerrit-review.googlesource.com/c/plugins/image-diff/+/271672).
-
-### Related resources
-
-- [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
--[What's new in Polymer 3.0](https://polymer-library.polymer-project.org/3.0/docs/about_30)
\ No newline at end of file
diff --git a/polygerrit-ui/app/api/admin.ts b/polygerrit-ui/app/api/admin.ts
index a7b549d..0606153 100644
--- a/polygerrit-ui/app/api/admin.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -16,13 +16,13 @@
  */
 
 /** Interface for menu link */
-export interface MenuLink {
+export declare interface MenuLink {
   text: string;
   url: string;
   capability: string | null;
 }
 
-export interface AdminPluginApi {
+export declare interface AdminPluginApi {
   addMenuLink(text: string, url: string, capability?: string): void;
 
   getMenuLinks(): MenuLink[];
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index bd4f399..ad0846d 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -36,7 +36,7 @@
   change?: unknown
 ) => Promise<Array<CoverageRange>>;
 
-export interface AnnotationPluginApi {
+export declare interface AnnotationPluginApi {
   /**
    * The specified function will be called when a gr-diff component is built,
    * and feeds the returned coverage data into the diff. Optional.
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
index cd52259..d813beb 100644
--- a/polygerrit-ui/app/api/attribute-helper.ts
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export interface AttributeHelperPluginApi {
+export declare interface AttributeHelperPluginApi {
   /**
    * Binds callback to property updates.
    *
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index 792f31e..2ce697a 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -16,7 +16,7 @@
  */
 import {HttpMethod} from './rest';
 
-export interface ActionInfo {
+export declare interface ActionInfo {
   method?: HttpMethod;
   label?: string;
   title?: string;
@@ -59,6 +59,7 @@
   UNIGNORE = 'unignore',
   UNREVIEWED = 'unreviewed',
   WIP = 'wip',
+  INCLUDED_IN = 'includedIn',
 }
 
 export enum RevisionActions {
@@ -70,7 +71,7 @@
 
 export type PrimaryActionKey = ChangeActions | RevisionActions;
 
-export interface ChangeActionsPluginApi {
+export declare interface ChangeActionsPluginApi {
   addPrimaryActionKey(key: PrimaryActionKey): void;
 
   removePrimaryActionKey(key: string): void;
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 6016004..bcbdc61 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -14,17 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export interface LabelsChangedDetail {
+export declare interface LabelsChangedDetail {
   name: string;
   value: string;
 }
-export interface ValueChangedDetail {
+export declare interface ValueChangedDetail {
   value: string;
 }
 export type ReplyChangedCallback = (text: string) => void;
 export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
 
-export interface ChangeReplyPluginApi {
+export declare interface ChangeReplyPluginApi {
   getLabelValue(label: string): string;
 
   setLabelValue(label: string, value: string): void;
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index ee4ea9e..5d20f3f 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export interface ChecksPluginApi {
+export declare interface ChecksPluginApi {
   /**
    * Must only be called once. You cannot register twice. You cannot unregister.
    */
@@ -29,7 +29,7 @@
   announceUpdate(): void;
 }
 
-export interface ChecksApiConfig {
+export declare interface ChecksApiConfig {
   /**
    * How often should the provider be called for new CheckData while the user
    * navigates change related pages and the browser tab remains visible?
@@ -38,7 +38,7 @@
   fetchPollingIntervalSeconds: number;
 }
 
-export interface ChangeData {
+export declare interface ChangeData {
   changeNumber: number;
   patchsetNumber: number;
   patchsetSha: string;
@@ -48,7 +48,7 @@
   changeInfo: unknown;
 }
 
-export interface ChecksProvider {
+export declare interface ChecksProvider {
   /**
    * Gerrit calls this method when ...
    * - ... the change or diff page is loaded.
@@ -59,7 +59,7 @@
   fetch(change: ChangeData): Promise<FetchResponse>;
 }
 
-export interface FetchResponse {
+export declare interface FetchResponse {
   responseCode: ResponseCode;
 
   /** Only relevant when the responseCode is ERROR. */
@@ -102,7 +102,7 @@
  * runs are completed the users' interest shifts to results: What do I have to
  * fix? The only actions that can be associated with runs are RUN and CANCEL.
  */
-export interface CheckRun {
+export declare interface CheckRun {
   /**
    * Gerrit requests check runs and results from the plugin by change number and
    * patchset number. So these two properties can as well be left empty when
@@ -221,7 +221,7 @@
   results?: CheckResult[];
 }
 
-export interface Action {
+export declare interface Action {
   name: string;
   tooltip?: string;
   /**
@@ -272,7 +272,7 @@
  * If `message` is empty or undefined, then the `Triggering ...` toast will just
  * be hidden and no further toast will be shown.
  */
-export interface ActionResult {
+export declare interface ActionResult {
   /** An empty errorMessage means success. */
   message?: string;
   /**
@@ -290,7 +290,7 @@
   COMPLETED = 'COMPLETED',
 }
 
-export interface CheckResult {
+export declare interface CheckResult {
   /**
    * An optional opaque identifier not used by Gerrit directly, but might be
    * used by plugin extensions and callbacks.
@@ -298,6 +298,11 @@
   externalId?: string;
 
   /**
+   * SUCCESS: Indicates that some build, test or check is passing. A COMPLETED
+   *          run without results will also be treated as "passing" and will get
+   *          an artificial SUCCESS result. But you can also make this explicit,
+   *          which also allows one run to have multiple "passing" results,
+   *          maybe along with results of other categories.
    * INFO:    The user will typically not bother to look into this category,
    *          only for looking up something that they are searching for. Can be
    *          used for reporting secondary metrics and analysis, or a wider
@@ -383,12 +388,13 @@
 }
 
 export enum Category {
+  SUCCESS = 'SUCCESS',
   INFO = 'INFO',
   WARNING = 'WARNING',
   ERROR = 'ERROR',
 }
 
-export interface Tag {
+export declare interface Tag {
   name: string;
   tooltip?: string;
   color?: TagColor;
@@ -404,7 +410,7 @@
   BROWN = 'brown',
 }
 
-export interface Link {
+export declare interface Link {
   /** Must begin with 'http'. */
   url: string;
   tooltip?: string;
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 9cd8cb3..f6b3aa0 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 declare interface SyntaxBlock {
+  /** Name of the block (e.g. name of the method/class)*/
+  name: string;
+  /** Where does this block syntatically starts and ends (line number and column).*/
+  range: {
+    /** first line of the block (1-based inclusive). */
+    start_line: number;
+    /**
+     * column of the range start inside the first line (e.g. "{" character ending a function/method)
+     * (1-based inclusive).
+     */
+    start_column: number;
+    /**
+     * last line of the block (1-based inclusive).
+     */
+    end_line: number;
+    /**
+     * column of the block end inside the end line (e.g. "}" character ending a function/method)
+     * (1-based inclusive).
+     */
+    end_column: number;
+  };
+  /** Sub-blocks of the current syntax block (e.g. methods of a class) */
+  children: SyntaxBlock[];
+}
+
+/**
  * The DiffFileMetaInfo entity contains meta information about a file diff.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
  */
@@ -65,6 +94,12 @@
   lines: number;
   // TODO: Not documented.
   language?: string;
+  /**
+   * The first level of syntax blocks tree (outline) within the current file.
+   * It contains an hierarchical structure where each block contains its
+   * sub-blocks (children).
+   */
+  syntax_tree?: SyntaxBlock[];
 }
 
 export declare type ChangeType =
@@ -178,6 +213,7 @@
   disable_context_control_buttons?: boolean;
   show_file_comment_button?: boolean;
   hide_line_length_indicator?: boolean;
+  use_block_expansion?: boolean;
 }
 
 /**
@@ -251,6 +287,32 @@
   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' | 'switch'}
+  | {type: 'zoom-level-changed'; scale: number | 'fit'}
+  | {type: 'follow-mouse-changed'; value: boolean}
+  | {type: 'background-color-changed'; value: string}
+  | {type: 'automatic-blink-changed'; value: boolean};
+
 export enum GrDiffLineType {
   ADD = 'add',
   BOTH = 'both',
@@ -285,6 +347,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/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
index 16c327d..5dc15dc 100644
--- a/polygerrit-ui/app/api/event-helper.ts
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -16,7 +16,7 @@
  */
 export type UnsubscribeCallback = () => void;
 
-export interface EventHelperPluginApi {
+export declare interface EventHelperPluginApi {
   /**
    * Alias for @see onClick
    */
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index 179b967..0ac6468 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -30,12 +30,12 @@
 
 export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
 
-export interface RegisterOptions {
+export declare interface RegisterOptions {
   slot?: string;
   replace: unknown;
 }
 
-export interface HookApi {
+export declare interface HookApi {
   onAttached(callback: HookCallback): HookApi;
 
   onDetached(callback: HookCallback): HookApi;
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 0aadb38..0c91546 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -48,7 +48,7 @@
   HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
 }
 
-export interface PluginApi {
+export declare interface PluginApi {
   _url?: URL;
   admin(): AdminPluginApi;
   annotationApi(): AnnotationPluginApi;
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index 60772cc..8d81831 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export interface PopupPluginApi {
+export declare interface PopupPluginApi {
   /**
    * Opens the popup, inserts it into DOM over current UI.
    * Creates the popup if not previously created. Creates popup content element,
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index 65bdc3f..c3655bb 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -18,7 +18,7 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
-export interface ReportingPluginApi {
+export declare interface ReportingPluginApi {
   reportInteraction(eventName: string, details?: EventDetails): void;
 
   reportLifeCycle(eventName: string, details?: EventDetails): void;
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index fd9cada..2b91bf6 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -26,7 +26,7 @@
 
 export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 
-export interface RestPluginApi {
+export declare interface RestPluginApi {
   getLoggedIn(): Promise<boolean>;
 
   getVersion(): Promise<string | undefined>;
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index be502f7..689347a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -54,6 +54,14 @@
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
   TAG_MERGED = 'autogenerated:gerrit:merged',
+  TAG_REVERT = 'autogenerated:gerrit:revert',
+}
+
+/**
+ * @desc Templates that can be used in change log messages.
+ */
+export enum ChangeMessageTemplate {
+  ACCOUNT_TEMPLATE = '<GERRIT_ACCOUNT_(\\d+)>',
 }
 
 /**
diff --git a/polygerrit-ui/app/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-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 2f07268..06087ed 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -28,10 +28,6 @@
     :host(:hover) {
       background-color: var(--hover-background-color);
     }
-    :host([needs-review]) {
-      font-weight: var(--font-weight-bold);
-      color: var(--primary-text-color);
-    }
     .container {
       position: relative;
     }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 8ce00f2..ea5339e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -33,7 +33,6 @@
   EmailAddress,
   PreferencesInput,
 } from '../../../types/common';
-import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {ChangeListViewState} from '../../../types/types';
 import {fireTitleChange} from '../../../utils/event-util';
@@ -278,13 +277,6 @@
       e.detail.starred
     );
   }
-
-  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.restApiService.saveChangeReviewed(
-      e.detail.change._number,
-      e.detail.reviewed
-    );
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
index 9914e70..5155596 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
@@ -77,7 +77,6 @@
       selected-index="{{viewState.selectedChangeIndex}}"
       show-star="[[_loggedIn]]"
       on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
     ></gr-change-list>
     <nav class$="[[_computeNavClass(_loading)]]">
       Page [[_computePage(_offset, _changesPerPage)]]
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 62cfcb4..8c33f52 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -28,7 +28,6 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  Modifier,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
@@ -38,7 +37,7 @@
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {changeIsOpen, isOwner} from '../../../utils/change-util';
+import {isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
@@ -47,13 +46,10 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {
-  hasAttention,
-  isAttentionSetEnabled,
-} from '../../../utils/attention-set-util';
+import {hasAttention} from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
-import {windowLocationReload} from '../../../utils/dom-util';
+import {fireEvent, fireReload} from '../../../utils/event-util';
+import {isShiftPressed} from '../../../utils/dom-util';
 import {ScrollMode} from '../../../constants/constants';
 
 const NUMBER_FIXED_COLUMNS = 3;
@@ -371,35 +367,18 @@
       : undefined;
   }
 
-  _computeItemNeedsReview(
-    account: AccountInfo | undefined,
-    change: ChangeInfo,
-    showReviewedState: boolean,
-    config?: ServerInfo
-  ) {
-    return (
-      !isAttentionSetEnabled(config) &&
-      showReviewedState &&
-      !change.reviewed &&
-      !change.work_in_progress &&
-      changeIsOpen(change) &&
-      (!account || account._account_id !== change.owner._account_id)
-    );
-  }
-
   _computeItemHighlight(
     account?: AccountInfo,
     change?: ChangeInfo,
-    config?: ServerInfo,
     sectionName?: string
   ) {
     if (!change || !account) return false;
     if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
-    return isAttentionSetEnabled(config)
-      ? hasAttention(config, account, change) &&
-          !isOwner(change, account) &&
-          sectionName === YOUR_TURN.name
-      : account._account_id === change.assignee?._account_id;
+    return (
+      hasAttention(account, change) &&
+      !isOwner(change, account) &&
+      sectionName === YOUR_TURN.name
+    );
   }
 
   _nextChange(e: CustomKeyboardEvent) {
@@ -439,8 +418,7 @@
   _nextPage(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+      (this.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
@@ -452,8 +430,7 @@
   _prevPage(e: CustomKeyboardEvent) {
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+      (this.modifierPressed(e) && !isShiftPressed(e))
     ) {
       return;
     }
@@ -492,11 +469,7 @@
     }
 
     e.preventDefault();
-    this._reloadWindow();
-  }
-
-  _reloadWindow() {
-    windowLocationReload();
+    fireReload(this);
   }
 
   _toggleChangeStar(e: CustomKeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index 317d27f..37d969e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -144,8 +144,7 @@
           <gr-change-list-item
             account="[[account]]"
             selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
-            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
+            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
             change="[[change]]"
             config="[[_config]]"
             section-name="[[changeSection.name]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 494d05a..13490d7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -192,82 +192,10 @@
       MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
       assert.equal(element.selectedIndex, 0);
 
-      const reloadStub = sinon.stub(element, '_reloadWindow');
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      assert.isTrue(reloadStub.called);
-
       done();
     });
   });
 
-  test('changes needing review', () => {
-    element.changes = [
-      {
-        _number: 0,
-        status: 'NEW',
-        reviewed: true,
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 1,
-        status: 'NEW',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 2,
-        status: 'MERGED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 3,
-        status: 'ABANDONED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 4,
-        status: 'NEW',
-        work_in_progress: true,
-        owner: {_account_id: 0},
-      },
-    ];
-    flush();
-    let elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
-
-    element.showReviewedState = true;
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-    element.account = {_account_id: 42};
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-    element._config = {
-      change: {enable_attention_set: true},
-    };
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
-  });
-
   test('no changes', () => {
     element.changes = [];
     flush();
@@ -575,37 +503,6 @@
       });
     });
 
-    test('highlight attribute is updated correctly', () => {
-      element.changes = [
-        {
-          _number: 0,
-          status: 'NEW',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 1,
-          status: 'ABANDONED',
-          owner: {_account_id: 0},
-        },
-      ];
-      element.account = {_account_id: 42};
-      flush();
-      let items = element._getListItems();
-      assert.equal(items.length, 2);
-      assert.isFalse(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-
-      // Assign all issues to the user, but only the first one is highlighted
-      // because the second one is abandoned.
-      element.set(['changes', 0, 'assignee'], {_account_id: 12});
-      element.set(['changes', 1, 'assignee'], {_account_id: 12});
-      element.account = {_account_id: 12};
-      flush();
-      items = element._getListItems();
-      assert.isTrue(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-    });
-
     test('_computeItemHighlight gives false for null account', () => {
       assert.isFalse(
           element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index f9dee5f..bde7d77 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -50,7 +50,6 @@
   GrCreateDestinationDialog,
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
@@ -129,10 +128,7 @@
   connectedCallback() {
     super.connectedCallback();
     this._loadPreferences();
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(this.params);
-    });
+    this.addEventListener('reload', () => this._reload(this.params));
     document.addEventListener('visibilitychange', () => {
       if (document.visibilityState === 'visible') {
         if (
@@ -391,26 +387,6 @@
     );
   }
 
-  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.restApiService.saveChangeReviewed(
-      e.detail.change._number,
-      e.detail.reviewed
-    );
-    // When a change is updated the same change may appear elsewhere in the
-    // dashboard (but is not the same object), so we must update other
-    // occurrences of the same change.
-    this._results?.forEach((dashboardChange, dashboardIndex) =>
-      dashboardChange.results.forEach((change, changeIndex) => {
-        if (change.id === e.detail.change.id) {
-          this.set(
-            `_results.${dashboardIndex}.results.${changeIndex}.reviewed`,
-            e.detail.reviewed
-          );
-        }
-      })
-    );
-  }
-
   /**
    * Banner is shown if a user is on their own dashboard and they have draft
    * comments on closed changes.
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index fd23970..fb6b391 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -78,13 +78,11 @@
     <h1 class="assistive-tech-only">Dashboard</h1>
     <gr-change-list
       show-star=""
-      show-reviewed-state=""
       account="[[account]]"
       preferences="[[preferences]]"
       selected-index="{{_selectedChangeIndex}}"
       sections="[[_results]]"
       on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
     >
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 165306e..473d2b9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -356,31 +356,6 @@
     assert.isFalse(differentChange.starred);
   });
 
-  test('toggling reviewed will update change everywhere', () => {
-    // It is important that the same change is represented by multiple objects
-    // and all are updated.
-    const change = {id: '5', reviewed: false};
-    const sameChange = {id: '5', reviewed: false};
-    const differentChange = {id: '4', reviewed: false};
-    element._results = [
-      {query: 'has:draft', results: [change]},
-      {query: 'is:open', results: [sameChange, differentChange]},
-    ];
-
-    element._handleToggleReviewed(
-        new CustomEvent('toggle-reviewed', {
-          detail: {
-            change,
-            reviewed: true,
-          },
-        })
-    );
-
-    assert.isTrue(change.reviewed);
-    assert.isTrue(sameChange.reviewed);
-    assert.isFalse(differentChange.reviewed);
-  });
-
   test('_showNewUserHelp', () => {
     element._loading = false;
     element._showNewUserHelp = false;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index e9a5a46..eb979a1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -26,7 +26,6 @@
 import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
 import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
 import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -35,7 +34,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {appContext} from '../../../services/app-context';
-import {fetchChangeUpdates, CURRENT} from '../../../utils/patch-set-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
   isOwner,
@@ -76,7 +75,6 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
-import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
 import {
   ConfirmRevertEventDetail,
   GrConfirmRevertDialog,
@@ -95,7 +93,7 @@
   GrChangeActionsElement,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
   CODE_REVIEW,
   getApprovalInfo,
@@ -111,6 +109,7 @@
   RevisionActions,
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -151,7 +150,6 @@
   rebase: 'Rebasing...',
   restore: 'Restoring...',
   revert: 'Reverting...',
-  revert_submission: 'Reverting Submission...',
   submit: 'Submitting...',
 };
 
@@ -186,6 +184,15 @@
   __type: ActionType.REVISION,
 };
 
+const INCLUDED_IN_ACTION: UIActionInfo = {
+  enabled: true,
+  label: 'Included In',
+  title: 'Open Included In dialog',
+  __key: 'includedIn',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
 const REBASE_EDIT: UIActionInfo = {
   enabled: true,
   label: 'Rebase edit',
@@ -245,7 +252,6 @@
   ChangeActions.REBASE_EDIT,
   ChangeActions.RESTORE,
   ChangeActions.REVERT,
-  ChangeActions.REVERT_SUBMISSION,
   ChangeActions.STOP_EDIT,
   QUICK_APPROVE_ACTION.key,
   RevisionActions.REBASE,
@@ -263,18 +269,15 @@
 const AWAIT_CHANGE_ATTEMPTS = 5;
 const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
+// TODO: Remove these once we are sure that the backend does not support/send
+// them anymore.
+const SKIP_ACTION_KEYS: string[] = [
   ChangeActions.REVIEWED,
   ChangeActions.UNREVIEWED,
+  ChangeActions.REVERT_SUBMISSION,
 ];
 
-function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+export function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
   // TODO(TS): Remove this function. The gr-change-actions adds properties
   // to existing ActionInfo objects instead of creating a new objects. This
   // function checks, that 'action' has all property required by UIActionInfo.
@@ -325,13 +328,14 @@
     confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
     confirmMove: GrConfirmMoveDialog;
     confirmRevertDialog: GrConfirmRevertDialog;
-    confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
     confirmAbandonDialog: GrConfirmAbandonDialog;
     confirmSubmitDialog: GrConfirmSubmitDialog;
     createFollowUpDialog: GrDialog;
     createFollowUpChange: GrCreateChangeDialog;
     confirmDeleteDialog: GrDialog;
     confirmDeleteEditDialog: GrDialog;
+    moreActions: GrDropdown;
+    secondaryActions: HTMLElement;
   };
 }
 
@@ -380,6 +384,8 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
+  private readonly changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -448,7 +454,7 @@
     computed:
       '_computeAllActions(actions.*, revisionActions.*,' +
       'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_config, _actionPriorityOverrides.*)',
+      '_actionPriorityOverrides.*)',
   })
   _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
 
@@ -525,6 +531,10 @@
       type: ActionType.CHANGE,
       key: ChangeActions.FOLLOW_UP,
     },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.INCLUDED_IN,
+    },
   ];
 
   @property({type: Array})
@@ -809,6 +819,10 @@
         this.set('revisionActions.download', DOWNLOAD_ACTION);
       }
     }
+    const actions = actionsChangeRecord.base || {};
+    if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
+      this.set('actions.includedIn', INCLUDED_IN_ACTION);
+    }
   }
 
   _deleteAndNotify(actionName: string) {
@@ -1175,23 +1189,6 @@
     });
   }
 
-  showRevertSubmissionDialog() {
-    const change = this.change;
-    if (!change) return;
-    const query = `submissionid:${change.submission_id}`;
-    this.restApiService.getChanges(0, query).then(changes => {
-      if (!changes) {
-        this.reporting.error(new Error('changes is undefined'));
-        return;
-      }
-      this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
-        change,
-        changes
-      );
-      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
-    });
-  }
-
   _handleActionTap(e: MouseEvent) {
     e.preventDefault();
     let el = (dom(e) as EventApi).localTarget as Element;
@@ -1266,9 +1263,6 @@
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
-      case ChangeActions.REVERT_SUBMISSION:
-        this.showRevertSubmissionDialog();
-        break;
       case ChangeActions.ABANDON:
         this._showActionDialog(this.$.confirmAbandonDialog);
         break;
@@ -1307,6 +1301,9 @@
       case ChangeActions.REBASE_EDIT:
         this._handleRebaseEditTap();
         break;
+      case ChangeActions.INCLUDED_IN:
+        this._handleIncludedInTap();
+        break;
       default:
         this._fireAction(
           this._prependSlash(key),
@@ -1451,9 +1448,11 @@
         );
         break;
       case RevertType.REVERT_SUBMISSION:
+        // TODO(dhruvsri): replace with this.actions.revert_submission once
+        // BE starts sending it again
         this._fireAction(
           '/revert_submission',
-          assertUIActionInfo(this.actions.revert_submission),
+          {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
           false,
           {message}
         );
@@ -1463,18 +1462,6 @@
     }
   }
 
-  _handleRevertSubmissionDialogConfirm() {
-    const el = this.$.confirmRevertSubmissionDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-      '/revert_submission',
-      assertUIActionInfo(this.actions.revert_submission),
-      false,
-      {message: el.message}
-    );
-  }
-
   _handleAbandonDialogConfirm() {
     const el = this.$.confirmAbandonDialog;
     this.$.overlay.close();
@@ -1649,13 +1636,7 @@
         case ChangeActions.REBASE_EDIT:
         case ChangeActions.REBASE:
         case ChangeActions.SUBMIT:
-          this.dispatchEvent(
-            new CustomEvent('reload', {
-              detail: {clearPatchset: true},
-              bubbles: false,
-              composed: true,
-            })
-          );
+          fireReload(this, true);
           break;
         case ChangeActions.REVERT_SUBMISSION: {
           const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
@@ -1672,22 +1653,12 @@
           break;
         }
         default:
-          this.dispatchEvent(
-            new CustomEvent('reload', {
-              detail: {action: action.__key, clearPatchset: true},
-              bubbles: false,
-              composed: true,
-            })
-          );
+          fireReload(this, true);
           break;
       }
     });
   }
 
-  _handleShowRevertSubmissionChangesConfirm() {
-    this._hideAllDialogs();
-  }
-
   _handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
@@ -1746,7 +1717,7 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return fetchChangeUpdates(change, this.restApiService).then(result => {
+    return this.changeService.fetchChangeUpdates(change).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
           new CustomEvent<ShowAlertEventDetail>('show-alert', {
@@ -1755,15 +1726,7 @@
                 'Cannot set label: a newer patch has been ' +
                 'uploaded to this change.',
               action: 'Reload',
-              callback: () => {
-                this.dispatchEvent(
-                  new CustomEvent('reload', {
-                    detail: {clearPatchset: true},
-                    bubbles: false,
-                    composed: true,
-                  })
-                );
-              },
+              callback: () => fireReload(this, true),
             },
             composed: true,
             bubbles: true,
@@ -1793,10 +1756,6 @@
     });
   }
 
-  _handleAbandonTap() {
-    this._showActionDialog(this.$.confirmAbandonDialog);
-  }
-
   _handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
@@ -1826,12 +1785,11 @@
   }
 
   _handleDownloadTap() {
-    this.dispatchEvent(
-      new CustomEvent('download-tap', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEvent(this, 'download-tap');
+  }
+
+  _handleIncludedInTap() {
+    fireEvent(this, 'included-tap');
   }
 
   _handleDeleteTap() {
@@ -1905,8 +1863,7 @@
       UIActionInfo[],
       UIActionInfo[]
     >,
-    change?: ChangeInfo,
-    config?: ServerInfo
+    change?: ChangeInfo
   ): UIActionInfo[] {
     // Polymer 2: check for undefined
     if (
@@ -1953,7 +1910,7 @@
         // End of hack
         return action;
       })
-      .filter(action => !this._shouldSkipAction(action, config));
+      .filter(action => !this._shouldSkipAction(action));
   }
 
   _getActionPriority(action: UIActionInfo) {
@@ -1992,14 +1949,8 @@
     }
   }
 
-  _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
-    const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
-    const isAttentionSetEnabled =
-      !!config && !!config.change && config.change.enable_attention_set;
-    if (isAttentionSetEnabled) {
-      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
-    }
-    return skipActionKeys.includes(action.__key);
+  _shouldSkipAction(action: UIActionInfo) {
+    return SKIP_ACTION_KEYS.includes(action.__key);
   }
 
   _computeTopLevelActions(
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 2d4ed32..614339a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -90,6 +90,7 @@
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
           data-action-key$="[[action.__key]]"
+          class$="[[action.__key]]"
           data-action-type$="[[action.__type]]"
           data-label$="[[action.label]]"
           disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
@@ -118,6 +119,7 @@
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
           data-action-key$="[[action.__key]]"
+          class$="[[action.__key]]"
           data-action-type$="[[action.__type]]"
           data-label$="[[action.label]]"
           disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
@@ -194,14 +196,6 @@
       on-cancel="_handleConfirmDialogCancel"
       hidden=""
     ></gr-confirm-revert-dialog>
-    <gr-confirm-revert-submission-dialog
-      id="confirmRevertSubmissionDialog"
-      class="confirmDialog"
-      commit-message="[[commitMessage]]"
-      on-confirm="_handleRevertSubmissionDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-submission-dialog>
     <gr-confirm-abandon-dialog
       id="confirmAbandonDialog"
       class="confirmDialog"
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
deleted file mode 100644
index c193e60..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ /dev/null
@@ -1,2186 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-actions.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {
-  createAccountWithId,
-  createApproval,
-  createChange,
-  createChangeMessages,
-  createRevisions,
-} from '../../../test/test-data-generators.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
-
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
-suite('gr-change-actions tests', () => {
-  let element;
-
-  suite('basic tests', () => {
-    setup(() => {
-      stubRestApi('getChangeRevisionActions').returns(Promise.resolve({
-        cherrypick: {
-          method: 'POST',
-          label: 'Cherry Pick',
-          title: 'Cherry pick change to a different branch',
-          enabled: true,
-        },
-        rebase: {
-          method: 'POST',
-          label: 'Rebase',
-          title: 'Rebase onto tip of branch or parent change',
-          enabled: true,
-        },
-        submit: {
-          method: 'POST',
-          label: 'Submit',
-          title: 'Submit patch set 2 into master',
-          enabled: true,
-        },
-        revert_submission: {
-          method: 'POST',
-          label: 'Revert submission',
-          title: 'Revert this submission',
-          enabled: true,
-        },
-      }));
-      stubRestApi('send').callsFake((method, url, payload) => {
-        if (method !== 'POST') {
-          return Promise.reject(new Error('bad method'));
-        }
-        if (url === '/changes/test~42/revisions/2/submit') {
-          return Promise.resolve({
-            ok: true,
-            text() { return Promise.resolve(')]}\'\n{}'); },
-          });
-        } else if (url === '/changes/test~42/revisions/2/rebase') {
-          return Promise.resolve({
-            ok: true,
-            text() { return Promise.resolve(')]}\'\n{}'); },
-          });
-        }
-        return Promise.reject(new Error('bad url'));
-      });
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-
-      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = basicFixture.instantiate();
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-      element.actions = {
-        '/': {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        },
-      };
-      element.account = {
-        _account_id: 123,
-      };
-      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-
-      return element.reload();
-    });
-
-    test('show-revision-actions event should fire', done => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
-      element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('primary and secondary actions split properly', () => {
-      // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions.length, 1);
-      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
-      assert.equal(element._topLevelSecondaryActions.length,
-          element._topLevelActions.length - 1);
-    });
-
-    test('revert submission action is skipped', () => {
-      assert.equal(element._allActionValues.filter(action =>
-        action.__key === 'submit').length, 1);
-      assert.equal(element._allActionValues.filter(action =>
-        action.__key === 'revert_submission').length, 0);
-    });
-
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(element._shouldHideActions({base: {}}, false));
-      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
-    });
-
-    test('plugin revision actions', done => {
-      const stub = stubRestApi('getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.revisionActions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(stub.calledWith(
-            element.changeNum, element.latestPatchNum, '/plugin~action'));
-        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('plugin change actions', async () => {
-      const stub = stubRestApi('getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.actions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.actions['plugin~action']);
-      await flush();
-      assert.isTrue(stub.calledWith(
-          element.changeNum, undefined, '/plugin~action'));
-      assert.equal(element.actions['plugin~action'].__url, 'the-url');
-    });
-
-    test('not supported actions are filtered out', () => {
-      element.revisionActions = {followup: {}};
-      assert.equal(element.querySelectorAll(
-          'section gr-button[data-action-type="revision"]').length, 0);
-    });
-
-    test('getActionDetails', () => {
-      element.revisionActions = {
-        'plugin~action': {},
-        ...element.revisionActions,
-      };
-      assert.isUndefined(element.getActionDetails('rubbish'));
-      assert.strictEqual(element.revisionActions['plugin~action'],
-          element.getActionDetails('plugin~action'));
-      assert.strictEqual(element.revisionActions['rebase'],
-          element.getActionDetails('rebase'));
-    });
-
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = element.shadowRoot
-            .querySelector('[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot
-              .querySelector('[data-action-key="submit"]');
-          assert.isNotOk(buttonEl);
-
-          element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, false);
-          flush(() => {
-            const buttonEl = element.shadowRoot
-                .querySelector('[data-action-key="submit"]');
-            assert.isOk(buttonEl);
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
-    });
-
-    test('buttons exist', done => {
-      element._loading = false;
-      flush(() => {
-        const buttonEls = dom(element.root)
-            .querySelectorAll('gr-button');
-        const menuItems = element.$.moreActions.items;
-
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(buttonEls.length + menuItems.length,
-            element._allActionValues.length + 1);
-        assert.isFalse(element.hidden);
-        done();
-      });
-    });
-
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items
-            .filter(item => item.id.startsWith('delete'));
-        assert.equal(deleteItems.length, 1);
-        assert.notEqual(deleteItems[0].name);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
-    });
-
-    test('get revision object from change', () => {
-      const revObj = {_number: 2, foo: 'bar'};
-      const change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: revObj,
-        },
-      };
-      assert.deepEqual(element._getRevision(change, 2), revObj);
-    });
-
-    test('_actionComparator sort order', () => {
-      const actions = [
-        {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc-ro', __type: 'revision'},
-        {label: 'abc', __type: 'change'},
-        {label: 'def', __type: 'change'},
-        {label: 'def-p', __type: 'change', __primary: true},
-      ];
-
-      const result = actions.slice();
-      result.reverse();
-      result.sort(element._actionComparator.bind(element));
-      assert.deepEqual(result, actions);
-    });
-
-    test('submit change', () => {
-      const showSpy = sinon.spy(element, '_showActionDialog');
-      stubRestApi('getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="submit"]');
-      assert.ok(submitButton);
-      MockInteractions.tap(submitButton);
-
-      flush();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
-    });
-
-    test('submit change, tap on icon', done => {
-      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
-      stubRestApi('getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitIcon =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key="submit"] iron-icon');
-      assert.ok(submitIcon);
-      MockInteractions.tap(submitIcon);
-    });
-
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
-      assert.isTrue(fireStub.calledOnce);
-      assert.deepEqual(fireStub.lastCall.args,
-          ['/submit', element.revisionActions.submit, true]);
-    });
-
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
-      assert.isFalse(fireStub.called);
-    });
-
-    test('submit change with plugin hook', done => {
-      sinon.stub(element, '_canSubmitChange').callsFake(
-          () => false);
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="submit"]');
-        assert.ok(submitButton);
-        MockInteractions.tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
-    });
-
-    test('chain state', () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
-    });
-
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
-      const action = {__key: 'rebase', enabled: true};
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
-
-      action.__key = 'delete';
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.enabled = false;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-    });
-
-    test('rebase change', done => {
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm({detail: {base: '1234'}});
-        assert.deepEqual(fireActionStub.lastCall.args,
-            ['/rebase', rebaseAction, true, {base: '1234'}]);
-        done();
-      });
-    });
-
-    test('rebase change fires reload event', done => {
-      const eventStub = sinon.stub(element, 'dispatchEvent');
-      stubRestApi('getResponseObject').returns(
-          Promise.resolve({}));
-      element._handleResponse({__key: 'rebase'}, {});
-      flush(() => {
-        assert.isTrue(eventStub.called);
-        assert.equal(eventStub.lastCall.args[0].type, 'reload');
-        done();
-      });
-    });
-
-    test(`rebase dialog gets recent changes each time it's opened`, done => {
-      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      MockInteractions.tap(rebaseButton);
-      assert.isTrue(fetchChangesStub.calledOnce);
-
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        MockInteractions.tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
-    });
-
-    test('two dialogs are not shown at the same time', async () => {
-      element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      assert.ok(rebaseButton);
-      MockInteractions.tap(rebaseButton);
-      await flush();
-      assert.isFalse(element.$.confirmRebase.hidden);
-      stubRestApi('getChanges')
-          .returns(Promise.resolve([]));
-      element._handleCherrypickTap();
-      await flush();
-      assert.isTrue(element.$.confirmRebase.hidden);
-      assert.isFalse(element.$.confirmCherrypick.hidden);
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('_setReviewOnRevert', () => {
-      const review = {labels: {'Foo': 1, 'Bar-Baz': -2}};
-      const changeId = 1234;
-      sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
-      const saveStub = stubRestApi('saveChangeReview')
-          .returns(Promise.resolve());
-      return element._setReviewOnRevert(changeId).then(() => {
-        assert.isTrue(saveStub.calledOnce);
-        assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], review);
-      });
-    });
-
-    suite('change edits', () => {
-      test('disableEdit', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        element.set('disableEdit', true);
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('shows confirm dialog for delete edit', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteEditDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.equal(fireActionStub.lastCall.args[0], '/edit');
-      });
-
-      test('hide publishEdit and rebaseEdit if change is not open', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'MERGED'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-      });
-
-      test('edit patchset is loaded, needs rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = false;
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit patchset is loaded, does not need rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = true;
-        flush();
-
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit mode is loaded, no edit patchset', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('normal patch set', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit action', done => {
-        element.addEventListener('edit-tap', () => { done(); });
-        element.set('editMode', true);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-        element.change = {status: 'MERGED'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        element.change = {status: 'NEW'};
-        element.set('editMode', false);
-        flush();
-
-        const editButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]');
-        assert.isOk(editButton);
-        MockInteractions.tap(editButton);
-      });
-    });
-
-    suite('cherry-pick', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        sinon.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: '',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmCherrypick.branch = 'master';
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-
-        assert.equal(element.$.confirmCherrypick.shadowRoot.
-            querySelector('#messageInput').value, 'foo message');
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: false,
-          },
-        ]);
-      });
-
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element.$.confirmCherrypick.branch = 'master';
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConflictConfirm();
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: true,
-          },
-        ]);
-      });
-
-      test('branch name cleared when re-open cherrypick', () => {
-        const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master';
-
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
-      });
-
-      suite('cherry pick topics', () => {
-        const changes = [
-          {
-            change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A', status: 'MERGED',
-          },
-          {
-            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-            project: 'B', status: 'NEW',
-          },
-        ];
-        setup(done => {
-          stubRestApi('getChanges')
-              .returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = element.$.confirmCherrypick.shadowRoot.
-                querySelectorAll(`input[name='cherryPickOptions']`);
-            assert.equal(radioButtons.length, 2);
-            MockInteractions.tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
-        });
-
-        test('cherry pick topic dialog is rendered', done => {
-          const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = dialog.shadowRoot.querySelector('table');
-            const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = ['', 'Change', 'Status', 'Subject',
-              'Project', 'Progress', ''];
-            const headings = headers.map(header => header.innerText);
-            assert.equal(headings.length, expectedHeadings.length);
-            for (let i = 0; i < headings.length; i++) {
-              assert.equal(headings[i].trim(), expectedHeadings[i]);
-            }
-            const changeRows = changesTable.querySelectorAll('tbody > tr');
-            const change = Array.from(changeRows[0].querySelectorAll('td'))
-                .map(e => e.innerText);
-            const expectedChange = ['', '1234567890', 'MERGED', 'random', 'A',
-              'NOT STARTED', ''];
-            for (let i = 0; i < change.length; i++) {
-              assert.equal(change[i].trim(), expectedChange[i]);
-            }
-            done();
-          });
-        });
-
-        test('changes with duplicate project show an error', done => {
-          const dialog = element.$.confirmCherrypick;
-          const error = dialog.shadowRoot.querySelector('.error-message');
-          assert.equal(error.innerText, '');
-          dialog.updateChanges([
-            {
-              change_id: '12345678901234', topic: 'T', subject: 'random',
-              project: 'A',
-            },
-            {
-              change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-              project: 'A',
-            },
-          ]);
-          flush(() => {
-            assert.equal(error.innerText, 'Two changes cannot be of the same'
-             + ' project');
-            done();
-          });
-        });
-      });
-    });
-
-    suite('move change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        sinon.stub(window, 'alert');
-        element.actions = {
-          move: {
-            method: 'POST',
-            label: 'Move',
-            title: 'Move the change',
-            enabled: true,
-          },
-        };
-      });
-
-      test('works', () => {
-        element._handleMoveTap();
-
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmMove.branch = 'master';
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 1);
-      });
-
-      test('branch name cleared when re-open move', () => {
-        const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master';
-
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
-      });
-    });
-
-    test('custom actions', done => {
-      // Add a button with the same key as a server-based one to ensure
-      // collisions are taken care of.
-      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
-        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
-        element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          done();
-        });
-      });
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-      });
-    });
-
-    test('_setLoadingOnButtonWithKey top-level', () => {
-      const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
-
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isTrue(button.hasAttribute('loading'));
-      assert.isTrue(button.disabled);
-
-      assert.isOk(cleanup);
-      assert.isFunction(cleanup);
-      cleanup();
-
-      assert.isFalse(button.hasAttribute('loading'));
-      assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
-    });
-
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
-      const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
-      assert.isFunction(cleanup);
-
-      cleanup();
-
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
-    });
-
-    suite('abandon change', () => {
-      let alertStub;
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
-        element.actions = {
-          abandon: {
-            method: 'POST',
-            label: 'Abandon',
-            title: 'Abandon the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('abandon change with message', done => {
-        const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
-      });
-
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
-      });
-
-      test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key="abandon"]');
-        MockInteractions.tap(restoreButton);
-
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
-        assert.notOk(alertStub.called);
-
-        const action = {
-          __key: 'abandon',
-          __type: 'change',
-          __primary: false,
-          enabled: true,
-          label: 'Abandon',
-          method: 'POST',
-          title: 'Abandon the change',
-        };
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/abandon', action, false, {
-            message: 'foo message',
-          }]);
-      });
-    });
-
-    suite('revert change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.commitMessage = 'random commit message';
-        element.change.current_revision = 'abcdef';
-        element.actions = {
-          revert: {
-            method: 'POST',
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('revert change with plugin hook', done => {
-        const newRevertMsg = 'Modified revert msg';
-        sinon.stub(element.$.confirmRevertDialog, '_modifyRevertMsg').callsFake(
-            () => newRevertMsg);
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        stubRestApi('getChanges')
-            .returns(Promise.resolve([
-              {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-            ]));
-        sinon.stub(element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage').callsFake(() => 'original msg');
-        flush(() => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
-      });
-
-      suite('revert change submitted together', () => {
-        let getChangesStub;
-        setup(() => {
-          element.change = {
-            submission_id: '199 0',
-            current_revision: '2000',
-          };
-          getChangesStub = stubRestApi('getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-              ]));
-        });
-
-        test('confirm revert dialog shows both options', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = confirmRevertDialog
-                .shadowRoot.querySelector('.revertSingleChange');
-            const revertSubmissionLabel = confirmRevertDialog.
-                shadowRoot.querySelector('.revertSubmission');
-            assert(revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change');
-            assert(revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)');
-            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890:random' + '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
-               + 'commit 2000.\n\nReason'
-               + ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('message modification is retained on switching', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
-            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-            'Reverted Changes:' + '\n' +
-            '1234567890:random' + '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
-            const singleChangeMsg =
-            'Revert "random commit message"\n\nThis reverts '
-              + 'commit 2000.\n\nReason'
-              + ' for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-            const newRevertMsg = revertSubmissionMsg + 'random';
-            const newSingleChangeMsg = singleChangeMsg + 'random';
-            confirmRevertDialog._message = newRevertMsg;
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              MockInteractions.tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                MockInteractions.tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                      confirmRevertDialog._message,
-                      newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
-        });
-      });
-
-      suite('revert single change', () => {
-        setup(() => {
-          element.change = {
-            submission_id: '199',
-            current_revision: '2000',
-          };
-          stubRestApi('getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              ]));
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('confirm revert dialog shows no radio button', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            assert.equal(radioInputs.length, 0);
-            const msg = 'Revert "random commit message"\n\n'
-              + 'This reverts commit 2000.\n\nReason '
-              + 'for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, msg);
-            const editedMsg = msg + 'hello';
-            confirmRevertDialog._message += 'hello';
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-              assert.equal(fireActionStub.getCall(0).args[3].message,
-                  editedMsg);
-              done();
-            });
-          });
-        });
-      });
-    });
-
-    suite('mark change private', () => {
-      setup(() => {
-        const privateAction = {
-          __key: 'private',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Mark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          private: privateAction,
-        };
-
-        element.change.is_private = false;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the mark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          done();
-        });
-      });
-
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          element.setActionOverflow('change', 'private', false);
-          flush();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          done();
-        });
-      });
-    });
-
-    suite('unmark private change', () => {
-      setup(() => {
-        const unmarkPrivateAction = {
-          __key: 'private.delete',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Unmark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          'private.delete': unmarkPrivateAction,
-        };
-
-        element.change.is_private = true;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the unmark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          done();
-        });
-      });
-
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          element.setActionOverflow('change', 'private.delete', false);
-          flush();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          done();
-        });
-      });
-    });
-
-    suite('delete change', () => {
-      let fireActionStub;
-      let deleteAction;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        deleteAction = {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        };
-        element.actions = {
-          '/': deleteAction,
-        };
-      });
-
-      test('does not delete on action', () => {
-        element._handleDeleteTap();
-        assert.isFalse(fireActionStub.called);
-      });
-
-      test('shows confirm dialog', () => {
-        element._handleDeleteTap();
-        assert.isFalse(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flush();
-        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
-      });
-
-      test('hides delete confirm on cancel', () => {
-        element._handleDeleteTap();
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button:not([primary])'));
-        flush();
-        assert.isTrue(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        assert.isFalse(fireActionStub.called);
-      });
-    });
-
-    suite('ignore change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="ignore"]'));
-          });
-
-      test('ignoring change', () => {
-        assert.isOk(element.$.moreActions.shadowRoot
-            .querySelector('span[data-id="ignore-change"]'));
-        element.setActionOverflow('change', 'ignore', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="ignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="ignore-change"]'));
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-        element.setActionOverflow('change', 'unignore', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-      });
-    });
-
-    suite('reviewed change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const ReviewedAction = {
-          __key: 'reviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark reviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          reviewed: ReviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('action is enabled', () => {
-        assert.equal(element._allActionValues.filter(action =>
-          action.__key === 'reviewed').length, 1);
-      });
-
-      test('action is skipped when attention set is enabled', () => {
-        element._config = {
-          change: {enable_attention_set: true},
-        };
-        assert.equal(element._allActionValues.filter(action =>
-          action.__key === 'reviewed').length, 0);
-      });
-
-      test('make sure the reviewed button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="reviewed"]'));
-          });
-
-      test('reviewing change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-        element.setActionOverflow('change', 'reviewed', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="reviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-      });
-    });
-
-    suite('unreviewed change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const UnreviewedAction = {
-          __key: 'unreviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark unreviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unreviewed: UnreviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unreviewed button not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-      });
-
-      test('unreviewed change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-        element.setActionOverflow('change', 'unreviewed', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-      });
-    });
-
-    suite('quick approve', () => {
-      setup(() => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                '-1': '',
-                ' 0': '',
-                '+1': '',
-              },
-            },
-          },
-          permitted_labels: {
-            foo: ['-1', ' 0', '+1'],
-          },
-        };
-        flush();
-      });
-
-      test('added when can approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-      });
-
-      test('hide quick approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-        assert.isFalse(element._hideQuickApproveAction);
-
-        // Assert approve button gets removed from list of buttons.
-        element.hideQuickApproveAction();
-        flush();
-        const approveButtonUpdated =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButtonUpdated);
-        assert.isTrue(element._hideQuickApproveAction);
-      });
-
-      test('is first in list of secondary actions', () => {
-        const approveButton = element.$.secondaryActions
-            .querySelector('gr-button');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('not added when change is merged', () => {
-        element.change.status = ChangeStatus.MERGED;
-        flush(() => {
-          const approveButton =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key=\'review\']');
-          assert.isNull(approveButton);
-        });
-      });
-
-      test('not added when already approved', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              approved: {},
-              values: {},
-            },
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when label not permitted', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-          },
-          permitted_labels: {
-            bar: [],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approves when tapped', () => {
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']'));
-        flush();
-        assert.isTrue(fireActionStub.called);
-        assert.isTrue(fireActionStub.calledWith('/review'));
-        const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: 1});
-      });
-
-      test('not added when multiple labels are required without code review',
-          () => {
-            element.change = {
-              current_revision: 'abc1234',
-              labels: {
-                foo: {values: {}},
-                bar: {values: {}},
-              },
-              permitted_labels: {
-                foo: [' 0', '+1'],
-                bar: [' 0', '+1', '+2'],
-              },
-            };
-            flush();
-            const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-            assert.isNull(approveButton);
-          });
-
-      test('code review shown with multiple missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            'foo': {values: {}},
-            'bar': {values: {}},
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            'foo': [' 0', '+1'],
-            'bar': [' 0', '+1', '+2'],
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isOk(approveButton);
-      });
-
-      test('button label for missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                ' 0': '',
-                '+1': '',
-              },
-            },
-            bar: {approved: {}, values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('no quick approve if score is not maximal for a label', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approving label with a non-max score', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
-      });
-
-      test('added when can approve an already-approved code review label',
-          () => {
-            element.change = {
-              current_revision: 'abc1234',
-              labels: {
-                'Code-Review': {
-                  approved: {},
-                  values: {
-                    ' 0': '',
-                    '+1': '',
-                    '+2': '',
-                  },
-                },
-              },
-              permitted_labels: {
-                'Code-Review': [' 0', '+1', '+2'],
-              },
-            };
-            flush();
-            const approveButton =
-                element.shadowRoot
-                    .querySelector('gr-button[data-action-key=\'review\']');
-            assert.isNotNull(approveButton);
-          });
-
-      test('not added when the user has already approved', () => {
-        const vote = {
-          ...createApproval(),
-          _account_id: 123,
-          name: 'name',
-          value: 2,
-        };
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-              all: [vote],
-            },
-          },
-          permitted_labels: {
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when user owns the change', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          owner: createAccountWithId(123),
-          labels: {
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-    });
-
-    test('adds download revision action', () => {
-      const handler = sinon.stub();
-      element.addEventListener('download-tap', handler);
-      assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      flush();
-
-      assert.isTrue(handler.called);
-    });
-
-    test('changing changeNum or patchNum does not reload', () => {
-      const reloadStub = sinon.stub(element, 'reload');
-      element.changeNum = 123;
-      assert.isFalse(reloadStub.called);
-      element.latestPatchNum = 456;
-      assert.isFalse(reloadStub.called);
-    });
-
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
-    });
-
-    suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-        element.setActionOverflow('revision', 'cherrypick', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.notEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-      });
-
-      test('move action to overflow', () => {
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        element.setActionOverflow('revision', 'submit', true);
-        flush();
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[3].id, 'submit-revision');
-      });
-
-      suite('_waitForChangeReachable', () => {
-        let clock;
-        setup(() => {
-          clock = sinon.useFakeTimers();
-        });
-
-        const makeGetChange = numTries => () => {
-          if (numTries === 1) {
-            return Promise.resolve({_number: 123});
-          } else {
-            numTries--;
-            return Promise.resolve(undefined);
-          }
-        };
-
-        const tickAndFlush = async repetitions => {
-          for (let i = 1; i <= repetitions; i++) {
-            clock.tick(1000);
-            await flush();
-          }
-        };
-
-        test('succeed', async () => {
-          stubRestApi('getChange').callsFake(makeGetChange(5));
-          const promise = element._waitForChangeReachable(123);
-          tickAndFlush(5);
-          const success = await promise;
-          assert.isTrue(success);
-        });
-
-        test('fail', async () => {
-          stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(123);
-          tickAndFlush(6);
-          const success = await promise;
-          assert.isFalse(success);
-        });
-      });
-    });
-
-    suite('_send', () => {
-      let cleanup;
-      let payload;
-      let onShowError;
-      let onShowAlert;
-      let getResponseObjectStub;
-
-      setup(() => {
-        cleanup = sinon.stub();
-        element.changeNum = 42;
-        element.change._number = 42;
-        element.latestPatchNum = 12;
-        element.change = {
-          ...createChange(),
-          revisions: createRevisions(element.latestPatchNum),
-          messages: createChangeMessages(1),
-        };
-        payload = {foo: 'bar'};
-
-        onShowError = sinon.stub();
-        element.addEventListener('show-error', onShowError);
-        onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
-      });
-
-      suite('happy path', () => {
-        let sendStub;
-        setup(() => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // element has latest info
-                revisions: createRevisions(element.latestPatchNum),
-                messages: createChangeMessages(1),
-              }));
-          sendStub = stubRestApi('executeChangeAction')
-              .returns(Promise.resolve({}));
-          getResponseObjectStub = stubRestApi(
-              'getResponseObject');
-          sinon.stub(GerritNav,
-              'navigateToChange').returns(Promise.resolve(true));
-        });
-
-        test('change action', async () => {
-          await element._send('DELETE', payload, '/endpoint', false, cleanup);
-          assert.isFalse(onShowError.called);
-          assert.isTrue(cleanup.calledOnce);
-          assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-              undefined, payload));
-        });
-
-        suite('show revert submission dialog', () => {
-          setup(() => {
-            element.change.submission_id = '199';
-            element.change.current_revision = '2000';
-            stubRestApi('getChanges')
-                .returns(Promise.resolve([
-                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                  {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-                ]));
-          });
-
-          test('revert submission shows submissionId', done => {
-            const expectedMsg = 'Revert submission 199' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890: random' + '\n' +
-              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const modifiedMsg = expectedMsg + 'abcd';
-            sinon.stub(element.$.confirmRevertSubmissionDialog,
-                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
-            element.showRevertSubmissionDialog();
-            flush(() => {
-              const msg = element.$.confirmRevertSubmissionDialog.message;
-              assert.equal(msg, modifiedMsg);
-              done();
-            });
-          });
-        });
-
-        suite('single changes revert', () => {
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345},
-                ]}));
-            navigateToSearchQueryStub = sinon.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission single change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).
-                  then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-            });
-          });
-        });
-
-        suite('multiple changes revert', () => {
-          let showActionDialogStub;
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345, topic: 'T'},
-                  {change_id: 23456, topic: 'T'},
-                ]}));
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
-            navigateToSearchQueryStub = sinon.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission multiple change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).then(
-                  () => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(navigateToSearchQueryStub.calledWith(
-                        'topic: T'));
-                    done();
-                  });
-            });
-          });
-        });
-
-        test('revision action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    12, payload));
-                done();
-              });
-        });
-      });
-
-      suite('failure modes', () => {
-        test('non-latest', () => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // new patchset was uploaded
-                revisions: createRevisions(element.latestPatchNum + 1),
-                messages: createChangeMessages(1),
-              }));
-          const sendStub = stubRestApi(
-              'executeChangeAction');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isTrue(onShowAlert.calledOnce);
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isFalse(sendStub.called);
-              });
-        });
-
-        test('send fails', () => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // element has latest info
-                revisions: createRevisions(element.latestPatchNum),
-                messages: createChangeMessages(1),
-              }));
-          const sendStub = stubRestApi(
-              'executeChangeAction').callsFake(
-              (num, method, patchNum, endpoint, payload, onErr) => {
-                onErr();
-                return Promise.resolve(null);
-              });
-          const handleErrorStub = sinon.stub(element, '_handleResponseError');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.called);
-                assert.isTrue(sendStub.calledOnce);
-                assert.isTrue(handleErrorStub.called);
-              });
-        });
-      });
-    });
-
-    test('_handleAction reports', () => {
-      sinon.stub(element, '_fireAction');
-      element.actions = {
-        key: {
-          __key: 'key',
-          __type: 'type',
-        },
-      };
-
-      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
-      element._handleAction('type', 'key');
-      assert.isTrue(reportStub.called);
-      assert.equal(reportStub.lastCall.args[0], 'type-key');
-    });
-  });
-
-  suite('getChangeRevisionActions returns only some actions', () => {
-    let element;
-
-    let changeRevisionActions;
-
-    setup(() => {
-      stubRestApi('getChangeRevisionActions').returns(
-          Promise.resolve(changeRevisionActions));
-      stubRestApi('send').returns(Promise.reject(new Error('error')));
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-
-      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = basicFixture.instantiate();
-      // getChangeRevisionActions is not called without
-      // set the following properties
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-
-      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
-    });
-
-    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
-      changeRevisionActions = {};
-      element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: 'POST',
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      delete rebaseAction.enabled;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
new file mode 100644
index 0000000..1790651
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -0,0 +1,2465 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-change-actions';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  createAccountWithId,
+  createApproval,
+  createChange,
+  createChangeMessages,
+  createChangeViewChange,
+  createRevision,
+  createRevisions,
+} from '../../../test/test-data-generators';
+import {ChangeStatus, HttpMethod} from '../../../constants/constants';
+import {
+  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('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 ed38d6f..ca68926 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -172,7 +172,14 @@
     <section
       class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
     >
-      <span class="title">Owner</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user created or uploaded the first patchset of this change."
+        >
+          Owner
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[change.owner]]"
@@ -194,7 +201,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-      <span class="title">Uploader</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user uploaded the patchset to Gerrit (typically by running the 'git push' command)."
+        >
+          Uploader
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
@@ -204,7 +218,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-      <span class="title">Author</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user wrote the code change."
+        >
+          Author
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
@@ -213,7 +234,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-      <span class="title">Committer</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
+        >
+          Committer
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
@@ -358,6 +386,22 @@
         </span>
       </section>
     </template>
+    <template is="dom-if" if="[[_showRevertCreatedAs(change)]]">
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVERT_CREATED_AS)]]"
+      >
+        <span class="title"
+          >[[_getRevertSectionTitle(change, revertedChange)]]</span
+        >
+        <span class="value">
+          <gr-commit-info
+            change="[[change]]"
+            commit-info="[[_computeRevertCommit(change, revertedChange)]]"
+            server-config="[[serverConfig]]"
+          ></gr-commit-info>
+        </span>
+      </section>
+    </template>
     <section
       class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
     >
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 89f7b89..dd19a76 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -287,9 +287,23 @@
   @property()
   loginCallback?: () => void;
 
-  /** Is reset when rendering beings and decreases while chips are rendered. */
+  /**
+   * How many check chips may still be rendered as a detailed chip. Is reset
+   * when rendering begins and decreases while chips are rendered. So when
+   * there are two ERRORs, then those would consume 2 from this quota and then
+   * there would only by DETAILS_QUOTA - 2 left for the other summary chips.
+   * Once there are more results than quota left we will stop rendering
+   * detailed chips and fall back to just icon+number rendering.
+   */
   private detailsQuota = DETAILS_QUOTA;
 
+  /**
+   * Is reset when rendering begins and contains the check names of runs that
+   * have a detailed chip. We keep track of this such that we can ensure to not
+   * show two detailed chips with the same name.
+   */
+  private detailsCheckNames: string[] = [];
+
   constructor() {
     super();
     this.subscribe('runs', allRunsLatest$);
@@ -411,9 +425,17 @@
     if (runs.length === 0) {
       return html``;
     }
-    if (runs.length <= this.detailsQuota) {
+    // If a run has both an error and a warning result, then we only want to
+    // show a detailed chip with the expanded checkName once. For simplicity
+    // just stop rendering detailed chips completely as soon as we run into
+    // this by setting detailsQuota to 0 (after the if-block).
+    const hasDetailChipAlready = runs.some(run =>
+      this.detailsCheckNames.includes(run.checkName)
+    );
+    if (!hasDetailChipAlready && runs.length <= this.detailsQuota) {
       this.detailsQuota -= runs.length;
       return runs.map(run => {
+        this.detailsCheckNames.push(run.checkName);
         const allLinks = resultFilter(run)
           .reduce(
             (links, result) => links.concat(result.links ?? []),
@@ -437,8 +459,8 @@
         </gr-checks-chip>`;
       });
     }
-    // runs.length > this.detailsQuota
     this.detailsQuota = 0;
+    this.detailsCheckNames = [];
     const sum = runs.reduce(
       (sum, run) => sum + (resultFilter(run).length || 1),
       0
@@ -465,6 +487,7 @@
 
   render() {
     this.detailsQuota = DETAILS_QUOTA;
+    this.detailsCheckNames = [];
     const commentThreads =
       this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
       [];
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..c6d2ee0 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,
@@ -162,6 +162,7 @@
   firePageError,
   fireDialogChange,
   fireTitleChange,
+  fireReload,
 } from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
@@ -169,6 +170,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 +180,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,9 +516,10 @@
   @property({type: String})
   _tabState?: TabState;
 
-  restApiService = appContext.restApiService;
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
 
-  checksService = appContext.checksService;
+  restApiService = appContext.restApiService;
 
   keyboardShortcuts() {
     return {
@@ -543,6 +549,8 @@
 
   private scrollTask?: DelayedTask;
 
+  private lastStarredTimestamp?: number;
+
   /** @override */
   ready() {
     super.ready();
@@ -624,7 +632,7 @@
     this.addEventListener('comment-discard', e =>
       this._handleCommentDiscard(e)
     );
-    this.addEventListener('change-message-deleted', () => this._reload());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
       this._handleCommitMessageSave(e)
     );
@@ -639,12 +647,8 @@
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
     );
-    this.addEventListener('show-secondary-tab', e =>
-      this._setActiveSecondaryTab(e)
-    );
     this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(
+      this.loadData(
         /* isLocationChange= */ false,
         /* clearPatchset= */ e.detail && e.detail.clearPatchset
       );
@@ -699,7 +703,7 @@
   }
 
   _onCloseFixPreview(e: CloseFixPreviewEvent) {
-    if (e.detail.fixApplied) this._reload();
+    if (e.detail.fixApplied) fireReload(this);
   }
 
   _handleToggleDiffMode(e: CustomKeyboardEvent) {
@@ -730,7 +734,8 @@
       activeTabName?: string;
       activeTabIndex?: number;
       scrollIntoView?: boolean;
-    }
+    },
+    src?: string
   ) {
     if (!paperTabs) return;
     const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
@@ -760,7 +765,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 +779,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 +810,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 +900,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;
@@ -1153,7 +1167,7 @@
       {once: true}
     );
     this.$.replyOverlay.cancel();
-    this._reload();
+    fireReload(this);
   }
 
   _handleReplyCancel() {
@@ -1206,6 +1220,14 @@
       return;
     }
 
+    // Everything in the change view is tied to the change. It seems better to
+    // force the re-creation of the change view when the change number changes.
+    const changeChanged = this._changeNum !== value.changeNum;
+    if (this._changeNum !== undefined && changeChanged) {
+      fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
+      return;
+    }
+
     if (value.changeNum && value.project) {
       this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
@@ -1216,7 +1238,6 @@
       value.basePatchNum !== undefined &&
       (this._patchRange.patchNum !== value.patchNum ||
         this._patchRange.basePatchNum !== value.basePatchNum);
-    const changeChanged = this._changeNum !== value.changeNum;
 
     let rightPatchNumChanged =
       this._patchRange &&
@@ -1231,9 +1252,13 @@
     this.$.fileList.collapseAllDiffs();
     this._patchRange = patchRange;
 
+    const patchKnown =
+      !patchRange.patchNum ||
+      (this._allPatchSets ?? []).some(ps => ps.num === patchRange.patchNum);
+
     // If the change has already been loaded and the parameter change is only
     // in the patch range, then don't do a full reload.
-    if (!changeChanged && patchChanged) {
+    if (!changeChanged && patchChanged && patchKnown) {
       if (!patchRange.patchNum) {
         patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
         rightPatchNumChanged = true;
@@ -1246,7 +1271,7 @@
 
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
-    this._reload(true).then(() => {
+    this.loadData(true).then(() => {
       this._performPostLoadTasks();
     });
 
@@ -1269,13 +1294,6 @@
         },
       })
     );
-    this._setActiveSecondaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: SecondaryTab.CHANGE_LOG,
-        },
-      })
-    );
   }
 
   _sendShowChangeEvent() {
@@ -1495,17 +1513,6 @@
     return GerritNav.getUrlForChange(change);
   }
 
-  _computeShowCommitInfo(
-    changeStatuses: string[],
-    current_revision: RevisionInfo
-  ) {
-    return (
-      changeStatuses.length === 1 &&
-      changeStatuses[0] === 'Merged' &&
-      current_revision
-    );
-  }
-
   _computeReplyButtonLabel(
     changeRecord?: ElementPropertyDeepChange<
       GrChangeView,
@@ -1652,7 +1659,7 @@
       return;
     }
     e.preventDefault();
-    this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+    fireReload(this, true);
   }
 
   _handleToggleChangeStar(e: CustomKeyboardEvent) {
@@ -1726,7 +1733,7 @@
           labelDict.approved &&
           labelDict.approved._account_id === removed._account_id
         ) {
-          this._reload();
+          fireReload(this);
           return;
         }
       }
@@ -1836,6 +1843,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 +1913,7 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.computeRevertSubmitted(change);
         this.changeService.updateChange(change);
         if (
           !this._patchRange ||
@@ -2051,7 +2084,7 @@
    * Some non-core data loading may still be in-flight when the core data
    * promise resolves.
    */
-  _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+  loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
     if (clearPatchset && this._change) {
       GerritNav.navigateToChange(this._change);
       return Promise.resolve([]);
@@ -2067,7 +2100,6 @@
     // are loaded.
     const detailCompletes = this._getChangeDetail();
     allDataPromises.push(detailCompletes);
-    this.checksService.reloadAll();
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
@@ -2258,10 +2290,6 @@
     );
   }
 
-  _computeReplyDisabled() {
-    return false;
-  }
-
   _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
     return `Change ${changeNum}`;
   }
@@ -2298,7 +2326,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;
@@ -2334,12 +2362,7 @@
               dismissOnNavigation: true,
               showDismiss: true,
               action: 'Reload',
-              callback: () => {
-                this._reload(
-                  /* isLocationChange= */ false,
-                  /* clearPatchset= */ true
-                );
-              },
+              callback: () => fireReload(this, true),
             },
             composed: true,
             bubbles: true,
@@ -2498,6 +2521,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..3d51ac6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -24,6 +24,7 @@
   DefaultBase,
   DiffViewMode,
   HttpMethod,
+  MessageTag,
   PrimaryTab,
   SecondaryTab,
 } from '../../../constants/constants';
@@ -77,6 +78,7 @@
   PatchRange,
   PatchSetNum,
   RelatedChangeAndCommitInfo,
+  ReviewInputTag,
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
@@ -103,6 +105,7 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {appContext} from '../../../services/app-context';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -377,7 +380,7 @@
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = fixture.instantiate();
-    element._changeNum = 1 as NumericChangeId;
+    element._changeNum = TEST_NUMERIC_CHANGE_ID;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
     getPluginLoader().loadPlugins([]);
     pluginApi.install(
@@ -515,7 +518,7 @@
 
   suite('plugins adding to file tab', () => {
     setup(done => {
-      element._changeNum = 1 as NumericChangeId;
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
       // Resolving it here instead of during setup() as other tests depend
       // on flush() not being called during setup.
       flush(() => done());
@@ -816,43 +819,6 @@
       });
     });
 
-    test('reload event from reply dialog is processed', () => {
-      const handleReloadStub = sinon.stub(element, '_reload');
-      element.$.replyDialog.dispatchEvent(
-        new CustomEvent('reload', {
-          detail: {clearPatchset: true},
-          bubbles: true,
-          composed: true,
-        })
-      );
-      assert.isTrue(handleReloadStub.called);
-    });
-
-    test('shift + R should fetch and navigate to the latest patch set', done => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element._change = {
-        ...createChangeViewChange(),
-        revisions: {
-          rev1: createRevision(),
-        },
-        current_revision: 'rev1' as CommitId,
-        status: ChangeStatus.NEW,
-        labels: {},
-        actions: {},
-      };
-
-      const reloadChangeStub = sinon.stub(element, '_reload');
-      pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      flush(() => {
-        assert.isTrue(reloadChangeStub.called);
-        done();
-      });
-    });
-
     test('d should open download overlay', () => {
       const stub = sinon
         .stub(element.$.downloadOverlay, 'open')
@@ -1102,7 +1068,7 @@
         '#relatedChanges'
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
-      sinon.stub(element, '_reload').returns(Promise.resolve([]));
+      sinon.stub(element, 'loadData').returns(Promise.resolve([]));
       sinon.spy(element, '_paramsChanged');
       element.params = createAppElementChangeViewParams();
     });
@@ -1265,6 +1231,153 @@
     assert.equal(statusChips.length, 2);
   });
 
+  suite('ChangeStatus revert', () => {
+    test('do not show any chip if no revert created', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
+    test('do not show any chip if all reverts are abandoned', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
+    test('show revert created if no revert is merged', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        assert.isTrue(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        done();
+      });
+    });
+
+    test('show revert submitted if revert is merged', done => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.MERGED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      flush();
+      element.computeRevertSubmitted(element._change);
+      flush(() => {
+        assert.isFalse(
+          element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        );
+        assert.isTrue(
+          element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        );
+        done();
+      });
+    });
+  });
+
   test('diff preferences open when open-diff-prefs is fired', () => {
     const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
     element.$.fileListHeader.dispatchEvent(
@@ -1330,7 +1443,7 @@
     };
     element._change = change;
     flush();
-    const reloadStub = sinon.stub(element, '_reload');
+    const reloadStub = sinon.stub(element, 'loadData');
     element.splice('_change.labels.test.all', 0, 1);
     assert.isFalse(reloadStub.called);
     change.labels.test.all.push(vote);
@@ -1503,7 +1616,7 @@
 
   test('don’t reload entire page when patchRange changes', () => {
     const reloadStub = sinon
-      .stub(element, '_reload')
+      .stub(element, 'loadData')
       .callsFake(() => Promise.resolve([]));
     const reloadPatchDependentStub = sinon
       .stub(element, '_reloadPatchNumDependentResources')
@@ -1520,6 +1633,13 @@
     assert.isTrue(reloadStub.calledOnce);
 
     element._initialLoadComplete = true;
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+    };
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
@@ -1530,7 +1650,7 @@
   });
 
   test('reload ported comments when patchNum changes', () => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
     sinon.stub(element, '_getCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
     flush();
@@ -1548,6 +1668,13 @@
     element._paramsChanged(value);
 
     element._initialLoadComplete = true;
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+    };
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
@@ -1557,7 +1684,7 @@
 
   test('reload entire page when patchRange doesnt change', () => {
     const reloadStub = sinon
-      .stub(element, '_reload')
+      .stub(element, 'loadData')
       .callsFake(() => Promise.resolve([]));
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
     const value: AppElementChangeViewParams = createAppElementChangeViewParams();
@@ -1570,13 +1697,13 @@
   });
 
   test('related changes are not updated after other action', done => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve([]));
     flush();
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
     sinon.stub(relatedChanges, 'reload');
-    element._reload(true).then(() => {
+    element.loadData(true).then(() => {
       assert.isFalse(navigateToChangeStub.called);
       done();
     });
@@ -1902,7 +2029,7 @@
     test('scrollTop is set correctly', () => {
       element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
 
-      sinon.stub(element, '_reload').callsFake(() => {
+      sinon.stub(element, 'loadData').callsFake(() => {
         // When element is reloaded, ensure that the history
         // state has the scrollTop set earlier. This will then
         // be reset.
@@ -2417,23 +2544,6 @@
     });
   });
 
-  test('_paramsChanged sets in projectLookup', () => {
-    flush();
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    sinon.stub(relatedChanges, 'reload');
-    sinon.stub(element, '_reload').returns(Promise.resolve([]));
-    const setStub = stubRestApi('setInProjectLookup');
-    element._paramsChanged({
-      view: GerritNav.View.CHANGE,
-      changeNum: 101 as NumericChangeId,
-      project: TEST_PROJECT_NAME,
-    });
-    assert.isTrue(setStub.calledOnce);
-    assert.isTrue(setStub.calledWith(101 as never, TEST_PROJECT_NAME as never));
-  });
-
   test('_handleToggleStar called when star is tapped', () => {
     element._change = {
       ...createChangeViewChange(),
@@ -2492,7 +2602,7 @@
       );
       element._paramsChanged({
         ...createAppElementChangeViewParams(),
-        changeNum: 101 as NumericChangeId,
+        changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
       });
       flush(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 3ba12b43..cc2f650 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -81,6 +81,18 @@
     });
   }
 
+  computeCommitLink(
+    webLink?: string,
+    change?: ChangeInfo,
+    commitInfo?: CommitInfo,
+    serverConfig?: ServerInfo
+  ) {
+    if (webLink) return webLink;
+    const hash = this._computeShortHash(change, commitInfo, serverConfig);
+    if (hash === undefined) return '';
+    return GerritNav.getUrlForSearchQuery(hash);
+  }
+
   _computeShortHash(
     change?: ChangeInfo,
     commitInfo?: CommitInfo,
@@ -90,7 +102,7 @@
       return '';
     }
 
-    const {name} = this._getWeblink(change, commitInfo, serverConfig) || {};
-    return name;
+    const weblink = this._getWeblink(change, commitInfo, serverConfig);
+    return weblink?.name ?? '';
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
index df0bb4a..65ca8b5 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
@@ -24,14 +24,12 @@
     }
   </style>
   <div class="container">
-    <template is="dom-if" if="[[_showWebLink]]">
-      <a target="_blank" rel="noopener" href$="[[_webLink]]"
-        >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
-      >
-    </template>
-    <template is="dom-if" if="[[!_showWebLink]]">
-      [[_computeShortHash(change, commitInfo, serverConfig)]]
-    </template>
+    <a
+      target="_blank"
+      rel="noopener"
+      href$="[[computeCommitLink(_webLink, change, commitInfo, serverConfig)]]"
+      >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
+    >
     <gr-copy-clipboard
       has-tooltip=""
       button-title="Copy full SHA to clipboard"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
index 17647ad..3ec4f2c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
@@ -32,10 +32,10 @@
     }
     .revertSubmissionLayout {
       display: flex;
+      align-items: center;
     }
     .label {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     iron-autogrow-textarea {
       font-family: var(--monospace-font-family);
@@ -47,6 +47,9 @@
       color: var(--error-text-color);
       margin-bottom: var(--spacing-m);
     }
+    label[for='messageInput'] {
+      margin-top: var(--spacing-m);
+    }
   </style>
   <gr-dialog
     confirm-label="Revert"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
deleted file mode 100644
index bd268fd..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-import {ChangeInfo} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
-
-const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
-
-@customElement('gr-confirm-revert-submission-dialog')
-export class GrConfirmRevertSubmissionDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  @property({type: String})
-  message?: string;
-
-  @property({type: String})
-  commitMessage?: string;
-
-  private readonly jsAPI = appContext.jsApiService;
-
-  _getTrimmedChangeSubject(subject: string) {
-    if (!subject) return '';
-    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-  }
-
-  _modifyRevertSubmissionMsg(change?: ChangeInfo) {
-    if (!change || !this.message || !this.commitMessage) {
-      return this.message;
-    }
-    return this.jsAPI.modifyRevertSubmissionMsg(
-      change,
-      this.message,
-      this.commitMessage
-    );
-  }
-
-  _populateRevertSubmissionMessage(
-    change?: ChangeInfo,
-    changes?: ChangeInfo[]
-  ) {
-    if (change === undefined) {
-      return;
-    }
-    // Follow the same convention of the revert
-    const commitHash = change.current_revision;
-    if (!commitHash) {
-      fireAlert(this, ERR_COMMIT_NOT_FOUND);
-      return;
-    }
-    const revertTitle = `Revert submission ${change.submission_id}`;
-    this.message =
-      revertTitle + '\n\n' + 'Reason for revert: <INSERT REASONING HERE>\n';
-    changes = changes || [];
-    if (changes.length) {
-      this.message += 'Reverted Changes:\n';
-      changes.forEach(change => {
-        this.message +=
-          `${change.change_id.substring(0, 10)}: ` +
-          `${this._getTrimmedChangeSubject(change.subject)}\n`;
-      });
-    }
-    this.message = this._modifyRevertSubmissionMsg(change);
-  }
-
-  _handleConfirmTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-
-  _handleCancelTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-confirm-revert-submission-dialog': GrConfirmRevertSubmissionDialog;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
deleted file mode 100644
index 48bd796..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <!-- TODO(taoalpha): move all shared styles to a style module. -->
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert Submission"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Revert Submission</div>
-    <div class="main" slot="main">
-      <label for="messageInput"> Revert Commit Message </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
deleted file mode 100644
index 1ed799f..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-revert-submission-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-revert-submission-dialog');
-
-suite('gr-confirm-revert-submission-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('no match', () => {
-    assert.isNotOk(element.message);
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSubmissionMessage(
-        'not a commitHash in sight', {}
-    );
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-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..1711499 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -25,7 +25,11 @@
 import '../../../styles/gr-voting-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
-import {MessageTag, SpecialFilePath} from '../../../constants/constants';
+import {
+  ChangeMessageTemplate,
+  MessageTag,
+  SpecialFilePath,
+} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -40,6 +44,7 @@
   PatchSetNum,
   AccountInfo,
   BasePatchSetNum,
+  AccountId,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -57,6 +62,7 @@
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
+const VOTE_RESET_TEXT = '0 (vote reset)';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -176,14 +182,19 @@
 
   @property({
     type: String,
-    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+    computed:
+      '_computeMessageContentExpanded(message.message,' +
+      ' message.accounts_in_message,' +
+      ' message.tag)',
   })
   _messageContentExpanded = '';
 
   @property({
     type: String,
     computed:
-      '_computeMessageContentCollapsed(message.message, message.tag,' +
+      '_computeMessageContentCollapsed(message.message,' +
+      ' message.accounts_in_message,' +
+      ' message.tag,' +
       ' message.commentThreads)',
   })
   _messageContentCollapsed = '';
@@ -231,8 +242,12 @@
     return pluralize(threadsLength, 'comment');
   }
 
-  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
-    return this._computeMessageContent(true, content, tag);
+  _computeMessageContentExpanded(
+    content?: string,
+    accountsInMessage?: AccountInfo[],
+    tag?: ReviewInputTag
+  ) {
+    return this._computeMessageContent(true, content, accountsInMessage, tag);
   }
 
   _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
@@ -261,10 +276,16 @@
 
   _computeMessageContentCollapsed(
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag,
     commentThreads?: CommentThread[]
   ) {
-    const summary = this._computeMessageContent(false, content, tag);
+    const summary = this._computeMessageContent(
+      false,
+      content,
+      accountsInMessage,
+      tag
+    );
     if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
@@ -319,11 +340,22 @@
   _computeMessageContent(
     isExpanded: boolean,
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag
   ) {
     if (!content) return '';
     const isNewPatchSet = this._isNewPatchsetTag(tag);
 
+    if (accountsInMessage) {
+      content = content.replace(
+        new RegExp(ChangeMessageTemplate.ACCOUNT_TEMPLATE, 'g'),
+        (_accountIdTemplate, accountId) =>
+          accountsInMessage.find(
+            account => account._account_id === (Number(accountId) as AccountId)
+          )?.name || `Gerrit Account ${accountId}`
+      );
+    }
+
     const lines = content.split('\n');
     const filteredLines = lines.filter(line => {
       if (!isExpanded && line.startsWith('>')) {
@@ -435,7 +467,7 @@
       )
       .map(ms => {
         const label = ms?.[2];
-        const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
         return {label, value};
       });
   }
@@ -448,7 +480,7 @@
     if (!score.value) {
       return '';
     }
-    if (score.value === 'removed') {
+    if (score.value.includes(VOTE_RESET_TEXT)) {
       return 'removed';
     }
     const classes = [];
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_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-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 8d5bc33..5595d15 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -21,13 +21,7 @@
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
 import {classMap} from 'lit-html/directives/class-map';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {
-  customElement,
-  property,
-  css,
-  internalProperty,
-  TemplateResult,
-} from 'lit-element';
+import {customElement, property, css, state, TemplateResult} from 'lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   SubmittedTogetherInfo,
@@ -76,22 +70,22 @@
   @property()
   mergeable?: boolean;
 
-  @internalProperty()
+  @state()
   submittedTogether?: SubmittedTogetherInfo = {
     changes: [],
     non_visible_changes: 0,
   };
 
-  @internalProperty()
+  @state()
   relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-  @internalProperty()
+  @state()
   conflictingChanges: ChangeInfo[] = [];
 
-  @internalProperty()
+  @state()
   cherryPickChanges: ChangeInfo[] = [];
 
-  @internalProperty()
+  @state()
   sameTopicChanges: ChangeInfo[] = [];
 
   private readonly restApiService = appContext.restApiService;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 5f35fd3..2767940 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -87,9 +87,6 @@
 
   test('_submit blocked when invalid email is supplied to ccs', () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sinon.stub(element, '_purgeReviewersPendingRemove');
 
     element.$.ccs.$.entry.setText('test');
     MockInteractions.tap(element.shadowRoot
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 2cabf90..e303dad 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -38,9 +38,12 @@
   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 {
+  accountKey,
+  accountOrGroupKey,
+  removeServiceUsers,
+} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
@@ -65,7 +68,6 @@
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
-  isGroup,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
   LabelNameToValueMap,
@@ -84,20 +86,17 @@
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
   PolymerDeepPropertyChange,
-  PolymerSplice,
   PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
   assertIsDefined,
-  assertNever,
   containsAll,
 } from '../../../utils/common-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {
   CODE_REVIEW,
   getApprovalInfo,
@@ -108,6 +107,7 @@
   fireAlert,
   fireEvent,
   fireIronAnnounce,
+  fireReload,
   fireServerError,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
@@ -149,15 +149,6 @@
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-interface PendingRemovals {
-  CC: (AccountInfoInput | GroupInfoInput)[];
-  REVIEWER: (AccountInfoInput | GroupInfoInput)[];
-}
-const PENDING_REMOVAL_KEYS: (keyof PendingRemovals)[] = [
-  ReviewerType.CC,
-  ReviewerType.REVIEWER,
-];
-
 export interface GrReplyDialog {
   $: {
     reviewers: GrAccountList;
@@ -226,6 +217,8 @@
 
   flagsService = appContext.flagsService;
 
+  changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeInfo;
 
@@ -310,12 +303,6 @@
   @property({type: Boolean, observer: '_handleHeightChanged'})
   _previewFormatting = false;
 
-  @property({type: Object})
-  _reviewersPendingRemove: PendingRemovals = {
-    CC: [],
-    REVIEWER: [],
-  };
-
   @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
   _sendButtonLabel?: string;
 
@@ -436,7 +423,7 @@
   open(focusTarget?: FocusTarget) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    fetchChangeUpdates(this.change, this.restApiService).then(result => {
+    this.changeService.fetchChangeUpdates(this.change).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
@@ -513,22 +500,21 @@
   }
 
   @observe('_ccs.splices')
-  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.CC);
   }
 
   @observe('_reviewers.splices')
-  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
   }
 
   _reviewerTypeChanged(
-    splices: PolymerSpliceChange<AccountInfo[]>,
+    splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>,
     reviewerType: ReviewerType
   ) {
     if (splices && splices.indexSplices) {
       this._reviewersMutated = true;
-      this._processReviewerChange(splices.indexSplices, reviewerType);
       let key: AccountId | EmailAddress | GroupId | undefined;
       let index;
       let account;
@@ -538,16 +524,16 @@
       for (const splice of splices.indexSplices) {
         for (let i = 0; i < splice.addedCount; i++) {
           account = splice.object[splice.index + i];
-          key = this._accountOrGroupKey(account);
+          key = accountOrGroupKey(account);
           const array = isReviewer ? this._ccs : this._reviewers;
           index = array.findIndex(
-            account => this._accountOrGroupKey(account) === key
+            account => accountOrGroupKey(account) === key
           );
           if (index >= 0) {
             this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
             const moveFrom = isReviewer ? 'CC' : 'reviewer';
             const moveTo = isReviewer ? 'reviewer' : 'CC';
-            const id = account.name || account.email || key;
+            const id = account.name || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
             fireAlert(this, message);
           }
@@ -556,76 +542,6 @@
     }
   }
 
-  _processReviewerChange(
-    indexSplices: Array<PolymerSplice<AccountInfo[]>>,
-    type: ReviewerType
-  ) {
-    for (const splice of indexSplices) {
-      for (const account of splice.removed) {
-        if (!this._reviewersPendingRemove[type]) {
-          this.reporting.error(new Error(`Invalid type ${type} for reviewer.`));
-          return;
-        }
-        this._reviewersPendingRemove[type].push(account);
-      }
-    }
-  }
-
-  /**
-   * Resets the state of the _reviewersPendingRemove object, and removes
-   * accounts if necessary.
-   *
-   * @param isCancel true if the action is a cancel.
-   * @param keep map of account IDs that must
-   * not be removed, because they have been readded in another state.
-   */
-  _purgeReviewersPendingRemove(
-    isCancel: boolean,
-    keep = new Map<AccountId | EmailAddress, boolean>()
-  ) {
-    let reviewerArr: (AccountInfoInput | GroupInfoInput)[];
-    for (const type of PENDING_REMOVAL_KEYS) {
-      if (!isCancel) {
-        reviewerArr = this._reviewersPendingRemove[type];
-        for (let i = 0; i < reviewerArr.length; i++) {
-          const reviewer = reviewerArr[i];
-          if (!isAccount(reviewer) || !keep.get(accountKey(reviewer))) {
-            this._removeAccount(reviewer, type as ReviewerType);
-          }
-        }
-      }
-      this._reviewersPendingRemove[type] = [];
-    }
-  }
-
-  /**
-   * Removes an account from the change, both on the backend and the client.
-   * Does nothing if the account is a pending addition.
-   */
-  _removeAccount(
-    account: AccountInfoInput | GroupInfoInput,
-    type: ReviewerType
-  ) {
-    assertIsDefined(this.change, 'change');
-    if (account._pendingAdd || !isAccount(account)) {
-      return;
-    }
-
-    return this.restApiService
-      .removeChangeReviewer(this.change._number, accountKey(account))
-      .then((response?: Response) => {
-        if (!response?.ok || !this.change) return;
-
-        const reviewers = this.change.reviewers[type] || [];
-        for (let i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id === account._account_id) {
-            this.splice(`change.reviewers.${type}`, i, 1);
-            break;
-          }
-        }
-      });
-  }
-
   _mapReviewer(addition: AccountAddition): ReviewerInput {
     if (addition.account) {
       return {reviewer: accountKey(addition.account)};
@@ -638,10 +554,7 @@
     throw new Error('Reviewer must be either an account or a group.');
   }
 
-  send(
-    includeComments: boolean,
-    startReview: boolean
-  ): Promise<Map<AccountId | EmailAddress, boolean>> {
+  send(includeComments: boolean, startReview: boolean) {
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.$.labelScores.getLabelValues();
 
@@ -656,29 +569,27 @@
       reviewInput.ready = true;
     }
 
-    if (isAttentionSetEnabled(this.serverConfig)) {
-      const selfName = getDisplayName(this.serverConfig, this._account);
-      const reason = `${selfName} replied on the change`;
+    const selfName = getDisplayName(this.serverConfig, this._account);
+    const reason = `${selfName} replied on the change`;
 
-      reviewInput.ignore_automatic_attention_set_rules = true;
-      reviewInput.add_to_attention_set = [];
-      for (const user of this._newAttentionSet) {
-        if (!this._currentAttentionSet.has(user)) {
-          reviewInput.add_to_attention_set.push({user, reason});
-        }
+    reviewInput.ignore_automatic_attention_set_rules = true;
+    reviewInput.add_to_attention_set = [];
+    for (const user of this._newAttentionSet) {
+      if (!this._currentAttentionSet.has(user)) {
+        reviewInput.add_to_attention_set.push({user, reason});
       }
-      reviewInput.remove_from_attention_set = [];
-      for (const user of this._currentAttentionSet) {
-        if (!this._newAttentionSet.has(user)) {
-          reviewInput.remove_from_attention_set.push({user, reason});
-        }
-      }
-      this.reportAttentionSetChanges(
-        this._attentionExpanded,
-        reviewInput.add_to_attention_set,
-        reviewInput.remove_from_attention_set
-      );
     }
+    reviewInput.remove_from_attention_set = [];
+    for (const user of this._currentAttentionSet) {
+      if (!this._newAttentionSet.has(user)) {
+        reviewInput.remove_from_attention_set.push({user, reason});
+      }
+    }
+    this.reportAttentionSetChanges(
+      this._attentionExpanded,
+      reviewInput.add_to_attention_set,
+      reviewInput.remove_from_attention_set
+    );
 
     if (this.draft) {
       const comment: CommentInput = {
@@ -690,24 +601,21 @@
       };
     }
 
-    const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
-    reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
-      if (reviewer.account) {
-        accountAdditions.set(accountKey(reviewer.account), true);
-      }
-      return this._mapReviewer(reviewer);
-    });
-    const ccsEl = this.$.ccs;
-    if (ccsEl) {
-      for (const addition of ccsEl.additions()) {
-        if (addition.account) {
-          accountAdditions.set(accountKey(addition.account), true);
-        }
+    const addToReviewInput = (
+      additions: AccountAddition[],
+      state?: ReviewerState
+    ) => {
+      additions.forEach(addition => {
         const reviewer = this._mapReviewer(addition);
-        reviewer.state = ReviewerState.CC;
-        reviewInput.reviewers.push(reviewer);
-      }
-    }
+        if (state) reviewer.state = state;
+        reviewInput.reviewers?.push(reviewer);
+      });
+    };
+    reviewInput.reviewers = [];
+    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
+    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
+    addToReviewInput(this.$.reviewers.removals(), ReviewerState.REMOVED);
+    addToReviewInput(this.$.ccs.removals(), ReviewerState.REMOVED);
 
     this.disabled = true;
 
@@ -717,11 +625,11 @@
         if (!response) {
           // Null or undefined response indicates that an error handler
           // took responsibility, so just return.
-          return new Map<AccountId | EmailAddress, boolean>();
+          return;
         }
         if (!response.ok) {
           fireServerError(response);
-          return new Map<AccountId | EmailAddress, boolean>();
+          return;
         }
 
         this.draft = '';
@@ -733,7 +641,7 @@
           })
         );
         fireIronAnnounce(this, 'Reply sent');
-        return accountAdditions;
+        return;
       })
       .then(result => {
         this.disabled = false;
@@ -889,12 +797,12 @@
     fireEvent(this, 'iron-resize');
   }
 
-  _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
-    return isAttentionSetEnabled(config) && !attentionExpanded;
+  _showAttentionSummary(attentionExpanded?: boolean) {
+    return !attentionExpanded;
   }
 
-  _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
-    return isAttentionSetEnabled(config) && attentionExpanded;
+  _showAttentionDetails(attentionExpanded?: boolean) {
+    return attentionExpanded;
   }
 
   _computeAttentionButtonTitle(sendDisabled?: boolean) {
@@ -1163,12 +1071,6 @@
     return rev.uploader;
   }
 
-  _accountOrGroupKey(entry: AccountInfo | GroupInfo) {
-    if (isAccount(entry)) return accountKey(entry);
-    if (isGroup(entry)) return entry.id;
-    assertNever(entry, 'entry must be account or group');
-  }
-
   /**
    * Generates a function to filter out reviewer/CC entries. When isCCs is
    * truthy, the function filters out entries that already exist in this._ccs.
@@ -1194,9 +1096,9 @@
         return false;
       }
 
-      const key = this._accountOrGroupKey(entry);
+      const key = accountOrGroupKey(entry);
       const finder = (entry: AccountInfo | GroupInfo) =>
-        this._accountOrGroupKey(entry) === key;
+        accountOrGroupKey(entry) === key;
       if (isCCs) {
         return this._ccs.find(finder) === undefined;
       }
@@ -1223,7 +1125,7 @@
       })
     );
     this.$.textarea.closeDropdown();
-    this._purgeReviewersPendingRemove(true);
+    this.$.reviewers.clearPendingRemovals();
     this._rebuildReviewerArrays(this.change.reviewers, this._owner);
   }
 
@@ -1234,9 +1136,7 @@
       // the text field of the CC entry.
       return;
     }
-    this.send(this._includeComments, false).then(keepReviewers => {
-      this._purgeReviewersPendingRemove(false, keepReviewers);
-    });
+    this.send(this._includeComments, false);
   }
 
   _sendTapHandler(e: Event) {
@@ -1254,19 +1154,15 @@
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this._includeComments, this.canBeStarted)
-      .then(keepReviewers => {
-        this._purgeReviewersPendingRemove(false, keepReviewers);
-      })
-      .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-error', {
-            bubbles: true,
-            composed: true,
-            detail: {message: `Error submitting review ${err}`},
-          })
-        );
-      });
+    return this.send(this._includeComments, this.canBeStarted).catch(err => {
+      this.dispatchEvent(
+        new CustomEvent('show-error', {
+          bubbles: true,
+          composed: true,
+          detail: {message: `Error submitting review ${err}`},
+        })
+      );
+    });
   }
 
   _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
@@ -1368,13 +1264,7 @@
   }
 
   _reload() {
-    this.dispatchEvent(
-      new CustomEvent('reload', {
-        detail: {clearPatchset: true},
-        bubbles: false,
-        composed: true,
-      })
-    );
+    fireReload(this, true);
     this.cancel();
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index f458994..17e5e26 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -363,7 +363,7 @@
     </section>
     <div class="stickyBottom">
       <section
-        hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
+        hidden$="[[!_showAttentionSummary(_attentionExpanded)]]"
         class="attention"
       >
         <div class="attentionSummary">
@@ -429,7 +429,7 @@
         </div>
       </section>
       <section
-        hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
+        hidden$="[[!_showAttentionDetails(_attentionExpanded)]]"
         class="attention-detail"
       >
         <div class="attentionDetailsTitle">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index c1e2564..b51eb5f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -19,7 +19,7 @@
 import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
 import './gr-reply-dialog.js';
 import {mockPromise, stubStorage} from '../../../test/test-utils.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
+import {ReviewerState, SpecialFilePath} from '../../../constants/constants.js';
 import {appContext} from '../../../services/app-context.js';
 import {addListenerForTest} from '../../../test/test-utils.js';
 import {stubRestApi} from '../../../test/test-utils.js';
@@ -57,7 +57,7 @@
   let setDraftCommentStub;
   let eraseDraftCommentStub;
 
-  let lastId = 0;
+  let lastId = 1;
   const makeAccount = function() { return {_account_id: lastId++}; };
   const makeGroup = function() { return {id: lastId++}; };
 
@@ -144,47 +144,47 @@
         }));
   }
 
-  test('default to publishing draft comments with reply', done => {
+  function interceptSaveReview() {
+    let resolver;
+    const promise = new Promise(resolve => { resolver = resolve; });
+    stubSaveReview(review => { resolver(review); });
+    return promise;
+  }
+
+  test('default to publishing draft comments with reply', async () => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
 
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: false,
-              }],
-            },
-            reviewers: [],
-          });
-          assert.isFalse(element.$.commentList.hidden);
-          done();
-        });
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
 
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
+    const review = await saveReviewPromise;
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        'Verified': 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+          message: 'I wholeheartedly disapprove',
+          unresolved: false,
+        }],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
     });
+    assert.isFalse(element.$.commentList.hidden);
   });
 
   test('modified attention set', done => {
-    element.serverConfig = {
-      change: {enable_attention_set: true},
-    };
     element._newAttentionSet = new Set([314]);
     const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
     MockInteractions.tap(buttonEl);
@@ -203,17 +203,17 @@
   });
 
   function checkComputeAttention(status, userId, reviewerIds, ownerId,
-      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft = true,
       includeComments = true) {
     const user = {_account_id: userId};
     const reviewers = {base: reviewerIds.map(id => {
       return {_account_id: id};
     })};
-    const draftThreads = [
-      {comments: []},
-    ];
+    let draftThreads = [];
     if (hasDraft) {
-      draftThreads[0].comments.push({__draft: true, unresolved: true});
+      draftThreads = [
+        {comments: [{__draft: true, unresolved: true}]},
+      ];
     }
     replyToIds.forEach(id => draftThreads[0].comments.push({
       author: {_account_id: id},
@@ -240,7 +240,6 @@
   }
 
   test('computeNewAttention NEW', () => {
-    checkComputeAttention('NEW', null, [], 999, [], [], [999]);
     checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
     checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
     checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
@@ -264,14 +263,14 @@
   });
 
   test('computeNewAttention MERGED', () => {
-    checkComputeAttention('MERGED', null, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+    checkComputeAttention('MERGED', 1, [], 999, [], [], [], undefined, false);
+    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined);
     checkComputeAttention(
         'MERGED', 1, [], 999, [], [], [], undefined, true, false);
-    checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
+    checkComputeAttention('MERGED', 1, [], 999, [1], [], [], undefined, false);
+    checkComputeAttention('MERGED', 1, [22], 999, [], [], [], undefined, false);
+    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22], undefined,
+        false);
     checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
     checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
     checkComputeAttention('MERGED', 1, [], 1, [], [], []);
@@ -406,103 +405,45 @@
     assert.sameMembers(actualAccounts, [1, 2, 4]);
   });
 
-  test('toggle resolved checkbox', done => {
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+  test('toggle resolved checkbox', async () => {
     const checkboxEl = element.shadowRoot.querySelector(
         '#resolvedPatchsetLevelCommentCheckbox');
     MockInteractions.tap(checkboxEl);
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: true,
-              }],
-            },
-            reviewers: [],
-          });
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('keep draft comments with reply', done => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
-    assert.equal(element._includeComments, false);
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
 
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'KEEP',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: false,
-              }],
-            },
-            reviewers: [],
-          });
-          assert.isTrue(element.$.commentList.hidden);
-          done();
-        });
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
 
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
+    const review = await saveReviewPromise;
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        'Verified': 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+          message: 'I wholeheartedly disapprove',
+          unresolved: true,
+        }],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
     });
   });
 
-  test('label picker', done => {
+  test('label picker', async () => {
     element.draft = 'I wholeheartedly disapprove';
-    stubSaveReview(review => {
-      assert.deepEqual(review, {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {
-          'Code-Review': -1,
-          'Verified': -1,
-        },
-        comments: {
-          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          }],
-        },
-        reviewers: [],
-      });
-    });
+    const saveReviewPromise = interceptSaveReview();
 
     sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
       return {
@@ -511,22 +452,69 @@
       };
     });
 
-    element.addEventListener('send', () => {
-      // Flush to ensure properties are updated.
-      flush(() => {
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done sending reply.');
-        assert.equal(element.draft.length, 0);
-        done();
-      });
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
+    assert.isTrue(element.disabled);
+
+    const review = await saveReviewPromise;
+    await flush();
+    assert.isFalse(element.disabled,
+        'Element should be enabled when done sending reply.');
+    assert.equal(element.draft.length, 0);
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': -1,
+        'Verified': -1,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+          message: 'I wholeheartedly disapprove',
+          unresolved: false,
+        }],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
     });
+  });
+
+  test('keep draft comments with reply', async () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
+    assert.equal(element._includeComments, false);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    flush(() => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      assert.isTrue(element.disabled);
+    await flush();
+    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
+
+    const review = await saveReviewPromise;
+    await flush();
+    assert.deepEqual(review, {
+      drafts: 'KEEP',
+      labels: {
+        'Code-Review': 0,
+        'Verified': 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+          message: 'I wholeheartedly disapprove',
+          unresolved: false,
+        }],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
     });
   });
 
@@ -969,63 +957,7 @@
     });
   });
 
-  test('_processReviewerChange', () => {
-    const mockIndexSplices = function(toRemove) {
-      return [{
-        removed: [toRemove],
-      }];
-    };
-
-    element._processReviewerChange(
-        mockIndexSplices(makeAccount()), 'REVIEWER');
-    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
-  });
-
-  test('_purgeReviewersPendingRemove', () => {
-    const removeStub = sinon.stub(element, '_removeAccount');
-    const mock = function() {
-      element._reviewersPendingRemove = {
-        CC: [makeAccount()],
-        REVIEWER: [makeAccount(), makeAccount()],
-      };
-    };
-    const checkObjEmpty = function(obj) {
-      for (const prop of Object.keys(obj)) {
-        if (obj[prop].length) { return false; }
-      }
-      return true;
-    };
-    mock();
-    element._purgeReviewersPendingRemove(true); // Cancel
-    assert.isFalse(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-
-    mock();
-    element._purgeReviewersPendingRemove(false); // Submit
-    assert.isTrue(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-  });
-
-  test('_removeAccount', done => {
-    stubRestApi('removeChangeReviewer')
-        .returns(Promise.resolve({ok: true}));
-    const arr = [makeAccount(), makeAccount()];
-    element.change.reviewers = {
-      REVIEWER: arr.slice(),
-    };
-
-    element._removeAccount(arr[1], 'REVIEWER').then(() => {
-      assert.equal(element.change.reviewers.REVIEWER.length, 1);
-      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
-      done();
-    });
-  });
-
   test('moving from cc to reviewer', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
     flush();
 
     const reviewer1 = makeAccount();
@@ -1043,7 +975,6 @@
     assert.deepEqual(element._reviewers,
         [reviewer1, reviewer2, reviewer3, cc1]);
     assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
 
     element.push('_reviewers', cc4, cc3);
     flush();
@@ -1051,7 +982,6 @@
     assert.deepEqual(element._reviewers,
         [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
     assert.deepEqual(element._ccs, [cc2]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
   });
 
   test('update attention section when reviewers and ccs change', () => {
@@ -1107,10 +1037,6 @@
   });
 
   test('moving from reviewer to cc', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
     flush();
 
     const reviewer1 = makeAccount();
@@ -1128,7 +1054,6 @@
     assert.deepEqual(element._reviewers,
         [reviewer2, reviewer3]);
     assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
 
     element.push('_ccs', reviewer3, reviewer2);
     flush();
@@ -1136,15 +1061,9 @@
     assert.deepEqual(element._reviewers, []);
     assert.deepEqual(element._ccs,
         [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
-        [reviewer1, reviewer3, reviewer2]);
   });
 
   test('migrate reviewers between states', async () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
     flush();
     const reviewers = element.$.reviewers;
     const ccs = element.$.ccs;
@@ -1160,11 +1079,6 @@
 
     stubSaveReview(review => mutations.push(...review.reviewers));
 
-    sinon.stub(element, '_removeAccount').callsFake((account, type) => {
-      mutations.push({state: 'REMOVED', account});
-      return Promise.resolve();
-    });
-
     // Remove and add to other field.
     reviewers.dispatchEvent(
         new CustomEvent('remove', {
@@ -1213,15 +1127,24 @@
     };
 
     // Send and purge and verify moves, delete cc3.
-    await element.send()
-        .then(keepReviewers =>
-          element._purgeReviewersPendingRemove(false, keepReviewers));
-    expect(mutations).to.have.lengthOf(5);
-    expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
-    expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
-    expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
-    expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
-    expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
+    await element.send();
+    expect(mutations).to.have.lengthOf(7);
+    expect(mutations[0]).to.deep.equal(mapReviewer(cc1,
+        ReviewerState.REVIEWER));
+    expect(mutations[1]).to.deep.equal(mapReviewer(cc2,
+        ReviewerState.REVIEWER));
+    expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1,
+        ReviewerState.CC));
+    expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2,
+        ReviewerState.CC));
+
+    // 3 remove events stored
+    expect(mutations[4]).to.deep.equal({reviewer: 33, state:
+        ReviewerState.REMOVED});
+    expect(mutations[5]).to.deep.equal({reviewer: 35, state:
+        ReviewerState.REMOVED});
+    expect(mutations[6]).to.deep.equal({reviewer: 37, state:
+        ReviewerState.REMOVED});
   });
 
   test('emits cancel on esc key', () => {
@@ -1544,9 +1467,6 @@
 
   test('_submit blocked when no mutations exist', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sinon.stub(element, '_purgeReviewersPendingRemove');
     element.account = makeAccount();
     element.draftCommentThreads = [];
     await flush();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 1c70350..ffcafdd 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -25,7 +25,6 @@
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
-  ServerInfo,
   LabelNameToValueMap,
   AccountInfo,
   ApprovalInfo,
@@ -41,6 +40,7 @@
 import {isRemovableReviewer} from '../../../utils/change-util';
 import {ReviewerState} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends PolymerElement {
@@ -60,9 +60,6 @@
   @property({type: Object})
   account?: AccountDetailInfo;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
@@ -200,17 +197,15 @@
     return maxScores.join(', ');
   }
 
-  @observe('change.reviewers.*', 'change.owner', 'serverConfig')
+  @observe('change.reviewers.*', 'change.owner')
   _reviewersChanged(
     changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo,
-    serverConfig: ServerInfo
+    owner: AccountInfo
   ) {
     // Polymer 2: check for undefined
     if (
       changeRecord === undefined ||
       owner === undefined ||
-      serverConfig === undefined ||
       this.change === undefined
     ) {
       return;
@@ -240,8 +235,8 @@
           if (isSelf(r1, this.account)) return -1;
           if (isSelf(r2, this.account)) return 1;
         }
-        const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
-        const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
+        const a1 = hasAttention(r1, this.change!) ? 1 : 0;
+        const a2 = hasAttention(r2, this.change!) ? 1 : 0;
         const s1 = isServiceUser(r1) ? -2 : 0;
         const s2 = isServiceUser(r2) ? -2 : 0;
         return a2 - a1 + s2 - s1;
@@ -261,32 +256,37 @@
   _handleRemove(e: Event) {
     e.preventDefault();
     const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change) {
-      return;
-    }
+    if (!target.account || !this.change?.reviewers) return;
     const accountID = target.account._account_id || target.account.email;
-    this.disabled = true;
     if (!accountID) return;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then((response: Response | undefined) => {
-        this.disabled = false;
-        if (!response || !response.ok) {
-          return response;
+    const reviewers = this.change.reviewers;
+    let removedAccount: AccountInfo | undefined;
+    let removedType: ReviewerState | undefined;
+    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+      const reviewerStateByType = reviewers[type] || [];
+      reviewers[type] = reviewerStateByType;
+      for (let i = 0; i < reviewerStateByType.length; i++) {
+        if (
+          reviewerStateByType[i]._account_id === accountID ||
+          reviewerStateByType[i].email === accountID
+        ) {
+          removedAccount = reviewerStateByType[i];
+          removedType = type;
+          this.splice(`change.reviewers.${type}`, i, 1);
+          break;
         }
-        if (!this.change || !this.change.reviewers) return;
-        const reviewers = this.change.reviewers;
-        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-          const reviewerStateByType = reviewers[type] || [];
-          reviewers[type] = reviewerStateByType;
-          for (let i = 0; i < reviewerStateByType.length; i++) {
-            if (
-              reviewerStateByType[i]._account_id === accountID ||
-              reviewerStateByType[i].email === accountID
-            ) {
-              this.splice('change.reviewers.' + type, i, 1);
-              break;
-            }
-          }
+      }
+    }
+    const curChange = this.change;
+    this.disabled = true;
+    this._xhrPromise = this._removeReviewer(accountID)
+      .then(response => {
+        this.disabled = false;
+        if (!this.change?.reviewers || this.change !== curChange) return;
+        if (!response?.ok) {
+          this.push(`change.reviewers.${removedType}`, removedAccount);
+          fireAlert(this, `Cannot remove a ${removedType}`);
+          return response;
         }
         return;
       })
@@ -326,3 +326,9 @@
     return this.restApiService.removeChangeReviewer(this.change._number, id);
   }
 }
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-reviewer-list': GrReviewerList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
deleted file mode 100644
index ddfe537..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ /dev/null
@@ -1,423 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-reviewer-list.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-reviewer-list');
-
-suite('gr-reviewer-list tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.serverConfig = {};
-
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-  });
-
-  test('controls hidden on immutable element', () => {
-    flush();
-    element.mutable = false;
-    assert.isTrue(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-    element.mutable = true;
-    assert.isFalse(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-  });
-
-  test('add reviewer button opens reply dialog', done => {
-    element.addEventListener('show-reply-dialog', () => {
-      done();
-    });
-    flush();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.addReviewer'));
-  });
-
-  test('only show remove for removable reviewers', () => {
-    element.mutable = true;
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            _account_id: 2,
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com',
-          },
-          {
-            _account_id: 3,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            _account_id: 4,
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com',
-          },
-          {
-            email: 'test@e.mail',
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3,
-          name: 'Pinky Penguin',
-        },
-        {
-          _account_id: 4,
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com',
-        },
-        {
-          email: 'test@e.mail',
-        },
-      ],
-    };
-    flush();
-    const chips =
-        element.root.querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account._account_id || el.account.email;
-      assert.ok(accountID);
-
-      const buttonEl = el.shadowRoot
-          .querySelector('gr-button');
-      assert.isNotNull(buttonEl);
-      if (accountID == 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  suite('_handleRemove', () => {
-    let removeReviewerStub;
-    let reviewersChangedSpy;
-
-    const reviewerWithId = {
-      _account_id: 2,
-      name: 'Some name',
-    };
-
-    const reviewerWithIdAndEmail = {
-      _account_id: 4,
-      name: 'Some other name',
-      email: 'example@',
-    };
-
-    const reviewerWithEmailOnly = {
-      email: 'example2@example',
-    };
-
-    let chips;
-
-    setup(() => {
-      removeReviewerStub = sinon
-          .stub(element, '_removeReviewer')
-          .returns(Promise.resolve(new Response({status: 200})));
-      element.mutable = true;
-
-      const allReviewers = [
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ];
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          REVIEWER: allReviewers,
-        },
-        removable_reviewers: allReviewers,
-      };
-      flush();
-      chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
-      assert.equal(chips.length, allReviewers.length);
-      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
-    });
-
-    test('_handleRemove for account with accountId only', async () => {
-      const accountChip = chips.find(chip =>
-        chip.account._account_id === reviewerWithId._account_id
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with accountId and email', async () => {
-      const accountChip = chips.find(chip =>
-        chip.account._account_id === reviewerWithIdAndEmail._account_id
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(
-          removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with email only', async () => {
-      const accountChip = chips.find(
-          chip => chip.account.email === reviewerWithEmailOnly.email
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-      ]);
-    });
-  });
-
-  test('tracking reviewers and ccs', () => {
-    let counter = 0;
-    function makeAccount() {
-      return {_account_id: counter++};
-    }
-
-    const owner = makeAccount();
-    const reviewer = makeAccount();
-    const cc = makeAccount();
-    const reviewers = {
-      REMOVED: [makeAccount()],
-      REVIEWER: [owner, reviewer],
-      CC: [owner, cc],
-    };
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
-
-    element.reviewersOnly = true;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer]);
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [cc]);
-  });
-
-  test('_handleAddTap passes mode with event', () => {
-    const fireStub = sinon.stub(element, 'dispatchEvent');
-    const e = {preventDefault() {}};
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {
-      reviewersOnly: false,
-      ccsOnly: false,
-    }});
-
-    element.reviewersOnly = true;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(
-        fireStub.lastCall.args[0].detail,
-        {value: {reviewersOnly: true, ccsOnly: false}});
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail,
-        {value: {ccsOnly: true, reviewersOnly: false}});
-  });
-
-  test('dont show all reviewers button with 4 reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('account owner comes first in list of reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i,
-            _account_id: i});
-    }
-    element.reviewersOnly = true;
-    element.account = {
-      _account_id: 1,
-    };
-    element.change = {
-      owner: {
-        _account_id: 111,
-      },
-      reviewers: {
-        REVIEWER: reviewers,
-      },
-    };
-    flush();
-    assert.equal(element._displayedReviewers[0]._account_id, 1);
-  });
-
-  test('show all reviewers button with 9 reviewers', () => {
-    const reviewers = [];
-    for (let i = 0; i < 9; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 9);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('show all reviewers button', () => {
-    const reviewers = [];
-    for (let i = 0; i < 100; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 94);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 100);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.hiddenReviewers'));
-
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('votable labels', () => {
-    const change = {
-      labels: {
-        Foo: {
-          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
-        },
-        Bar: {
-          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-            {_account_id: 7, permitted_voting_range: {max: 1}}],
-        },
-        FooBar: {
-          all: [{_account_id: 7, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change),
-        'Bar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 7}, change),
-        'Foo: +2, Bar, FooBar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 2}, change),
-        '');
-  });
-
-  test('fails gracefully when all is not included', () => {
-    const change = {
-      labels: {Foo: {}},
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change), '');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
new file mode 100644
index 0000000..4b57651
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -0,0 +1,475 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-reviewer-list';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrReviewerList} from './gr-reviewer-list';
+import {
+  createAccountDetailWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {AccountId, EmailAddress} from '../../../types/common';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+
+const basicFixture = fixtureFromElement('gr-reviewer-list');
+
+suite('gr-reviewer-list tests', () => {
+  let element: GrReviewerList;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+  });
+
+  test('controls hidden on immutable element', () => {
+    flush();
+    element.mutable = false;
+    assert.isTrue(
+      queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
+    );
+    element.mutable = true;
+    assert.isFalse(
+      queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
+    );
+  });
+
+  test('add reviewer button opens reply dialog', done => {
+    element.addEventListener('show-reply-dialog', () => {
+      done();
+    });
+    flush();
+    tap(queryAndAssert(element, '.addReviewer'));
+  });
+
+  test('only show remove for removable reviewers', () => {
+    element.mutable = true;
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(1),
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            ...createAccountDetailWithId(2),
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com' as EmailAddress,
+          },
+          {
+            _account_id: 3 as AccountId,
+            name: 'Pinky Penguin',
+          },
+        ],
+        CC: [
+          {
+            ...createAccountDetailWithId(4),
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com' as EmailAddress,
+          },
+          {
+            email: 'test@e.mail' as EmailAddress,
+          },
+        ],
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3 as AccountId,
+          name: 'Pinky Penguin',
+        },
+        {
+          ...createAccountDetailWithId(4),
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com' as EmailAddress,
+        },
+        {
+          email: 'test@e.mail' as EmailAddress,
+        },
+      ],
+    };
+    flush();
+    const chips = element.root!.querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
+
+    for (const el of Array.from(chips)) {
+      const accountID = el.account!._account_id || el.account!.email;
+      assert.ok(accountID);
+
+      const buttonEl = queryAndAssert(el, 'gr-button');
+      if (accountID === 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+      }
+    }
+  });
+
+  suite('_handleRemove', () => {
+    let removeReviewerStub: sinon.SinonStub;
+    let reviewersChangedSpy: sinon.SinonSpy;
+
+    const reviewerWithId = {
+      ...createAccountDetailWithId(2),
+      name: 'Some name',
+    };
+
+    const reviewerWithIdAndEmail = {
+      ...createAccountDetailWithId(4),
+      name: 'Some other name',
+      email: 'example@' as EmailAddress,
+    };
+
+    const reviewerWithEmailOnly = {
+      email: 'example2@example' as EmailAddress,
+    };
+
+    let chips: GrAccountChip[];
+
+    setup(() => {
+      removeReviewerStub = sinon
+        .stub(element, '_removeReviewer')
+        .returns(Promise.resolve(new Response()));
+      element.mutable = true;
+
+      const allReviewers = [
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ];
+
+      element.change = {
+        ...createChange(),
+        owner: {
+          ...createAccountDetailWithId(1),
+        },
+        reviewers: {
+          REVIEWER: allReviewers,
+        },
+        removable_reviewers: allReviewers,
+      };
+      flush();
+      chips = Array.from(element.root!.querySelectorAll('gr-account-chip'));
+      assert.equal(chips.length, allReviewers.length);
+      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+    });
+
+    test('_handleRemove for account with accountId only', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!._account_id === reviewerWithId._account_id
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with accountId and email', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!._account_id === reviewerWithIdAndEmail._account_id
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(
+        removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id)
+      );
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with email only', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!.email === reviewerWithEmailOnly.email
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+      ]);
+    });
+  });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++ as AccountId};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    const e = {...new Event(''), preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {
+        reviewersOnly: false,
+        ccsOnly: false,
+      },
+    });
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {reviewersOnly: true, ccsOnly: false},
+    });
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {ccsOnly: true, reviewersOnly: false},
+    });
+  });
+
+  test('dont show all reviewers button with 4 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 4; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 4);
+    assert.equal(element._reviewers.length, 4);
+    assert.isTrue(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('account owner comes first in list of reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 4; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.reviewersOnly = true;
+    element.account = {
+      ...createAccountDetailWithId(1),
+    };
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(11),
+      },
+      reviewers: {
+        REVIEWER: reviewers,
+      },
+    };
+    flush();
+    assert.equal(element._displayedReviewers[0]._account_id, 1 as AccountId);
+  });
+
+  test('show all reviewers button with 9 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 9; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 9);
+    assert.isFalse(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    for (let i = 0; i < 100; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 94);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+
+    tap(queryAndAssert(element, '.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('votable labels', () => {
+    const change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      'Bar'
+    );
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
+      'Foo: +2, Bar, FooBar'
+    );
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(2)}, change),
+      ''
+    );
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      ...createChange(),
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      ''
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_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/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
new file mode 100644
index 0000000..6dce9bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from 'lit-html';
+import {css, customElement, property} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {CheckRun} from '../../services/checks/checks-model';
+
+@customElement('gr-checks-attempt')
+class GrChecksAttempt extends GrLitElement {
+  @property()
+  run?: CheckRun;
+
+  static get styles() {
+    return [
+      css`
+        .attempt {
+          display: inline-block;
+          height: var(--line-height-normal);
+          vertical-align: top;
+          font-size: var(--font-size-small);
+          position: relative;
+        }
+        .attempt .box,
+        .attempt .angle {
+          box-sizing: border-box;
+          height: calc(var(--line-height-normal) - 2px);
+          line-height: calc(var(--line-height-normal) - 2px);
+          border-radius: 2px;
+        }
+        .attempt .box {
+          margin-left: 2px;
+          margin-bottom: 2px;
+          border: 1px solid var(--deemphasized-text-color);
+          padding: 0 var(--spacing-s);
+        }
+        .attempt .angle {
+          position: absolute;
+          top: 2px;
+          /* The text in the .angle div just ensures the correct width. */
+          color: transparent;
+          border-left: 1px solid var(--deemphasized-text-color);
+          border-bottom: 1px solid var(--deemphasized-text-color);
+          /* 1px for the border of the .box div. */
+          padding: 0 calc(var(--spacing-s) + 1px);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.run) return undefined;
+    if (this.run.isSingleAttempt) return undefined;
+    if (!this.run.attempt) return undefined;
+    const attempt = this.run.attempt;
+
+    return html`
+      <span class="attempt">
+        <div class="box">${attempt}</div>
+        <div class="angle">${attempt}</div>
+      </span>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-attempt': GrChecksAttempt;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 57bed1c..6f35c49 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -20,13 +20,14 @@
 import {
   css,
   customElement,
-  internalProperty,
   property,
   PropertyValues,
   query,
+  state,
   TemplateResult,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
+import './gr-checks-attempt';
 import '@polymer/paper-tooltip/paper-tooltip';
 import {
   Action,
@@ -39,17 +40,15 @@
 import {sharedStyles} from '../../styles/shared-styles';
 import {
   allActions$,
-  checksPatchsetNumber$,
-  someProvidersAreLoading$,
-  RunResult,
-  CheckRun,
   allLinks$,
+  CheckRun,
+  checksPatchsetNumber$,
+  RunResult,
+  someProvidersAreLoading$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
   fireActionTriggered,
-  hasCompletedWithoutResults,
-  hasResultsOf,
   iconForCategory,
   iconForLink,
   tooltipForLink,
@@ -115,16 +114,6 @@
           overflow: hidden;
           text-overflow: ellipsis;
         }
-        .nameCol .attempt {
-          display: inline-block;
-          background-color: var(--tag-gray);
-          border-radius: var(--line-height-normal);
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          text-align: center;
-          vertical-align: top;
-          font-size: var(--font-size-small);
-        }
         .summaryCol {
           /* Forces this column to get the remaining space that is left over by
              the other columns. */
@@ -169,6 +158,9 @@
           display: inline-block;
           margin-left: var(--spacing-s);
         }
+        tr.collapsed td .summary-cell .message {
+          color: var(--deemphasized-text-color);
+        }
         tr.collapsed td .summary-cell .links,
         tr.collapsed td .summary-cell .actions {
           display: none;
@@ -262,9 +254,7 @@
         <td class="nameCol" @click="${this.toggleExpanded}">
           <div>
             <span>${this.result.checkName}</span>
-            <span class="attempt" ?hidden="${this.result.isSingleAttempt}"
-              >${this.result.attempt}</span
-            >
+            <gr-checks-attempt .run="${this.result}"></gr-checks-attempt>
           </div>
         </td>
         <td class="summaryCol">
@@ -459,18 +449,18 @@
   }
 }
 
-const SHOW_ALL_THRESHOLDS: Map<Category | 'SUCCESS', number> = new Map();
+const SHOW_ALL_THRESHOLDS: Map<Category, number> = new Map();
 SHOW_ALL_THRESHOLDS.set(Category.ERROR, 20);
-SHOW_ALL_THRESHOLDS.set(Category.WARNING, 20);
+SHOW_ALL_THRESHOLDS.set(Category.WARNING, 10);
 SHOW_ALL_THRESHOLDS.set(Category.INFO, 5);
-SHOW_ALL_THRESHOLDS.set('SUCCESS', 5);
+SHOW_ALL_THRESHOLDS.set(Category.SUCCESS, 5);
 
 @customElement('gr-checks-results')
 export class GrChecksResults extends GrLitElement {
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @internalProperty()
+  @state()
   filterRegExp = new RegExp('');
 
   /** All runs. Shown should only the selected/filtered ones. */
@@ -510,22 +500,22 @@
   >();
 
   /** Maintains the state of which result sections should show all results. */
-  @internalProperty()
-  isShowAll: Map<Category | 'SUCCESS', boolean> = new Map();
+  @state()
+  isShowAll: Map<Category, boolean> = new Map();
 
   /**
    * This is the current state of whether a section is expanded or not. As long
    * as isSectionExpandedByUser is false this will be computed by a default rule
    * on every render.
    */
-  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpanded = new Map<Category, boolean>();
 
   /**
    * Keeps track of whether the user intentionally changed the expansion state.
    * Once this is true the default rule for showing a section expanded or not
    * is not applied anymore.
    */
-  private isSectionExpandedByUser = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpandedByUser = new Map<Category, boolean>();
 
   private readonly checksService = appContext.checksService;
 
@@ -729,7 +719,8 @@
       <div class="body">
         ${this.renderSection(Category.ERROR)}
         ${this.renderSection(Category.WARNING)}
-        ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
+        ${this.renderSection(Category.INFO)}
+        ${this.renderSection(Category.SUCCESS)}
       </div>
     `;
   }
@@ -856,16 +847,11 @@
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
-  renderSection(category: Category | 'SUCCESS') {
+  renderSection(category: Category) {
     const catString = category.toString().toLowerCase();
-    let allRuns = this.runs.filter(run =>
+    const allRuns = this.runs.filter(run =>
       isAttemptSelected(this.selectedAttempts, run)
     );
-    if (category === 'SUCCESS') {
-      allRuns = allRuns.filter(hasCompletedWithoutResults);
-    } else {
-      allRuns = allRuns.filter(r => hasResultsOf(r, category));
-    }
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
         ...results,
@@ -877,7 +863,8 @@
     const filtered = selected.filter(
       result =>
         this.filterRegExp.test(result.checkName) ||
-        this.filterRegExp.test(result.summary)
+        this.filterRegExp.test(result.summary) ||
+        this.filterRegExp.test(result.message ?? '')
     );
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
@@ -925,7 +912,7 @@
   }
 
   renderShowAllButton(
-    category: Category | 'SUCCESS',
+    category: Category,
     isShowAll: boolean,
     showAllThreshold: number,
     resultCount: number
@@ -944,7 +931,7 @@
     `;
   }
 
-  toggleShowAll(category: Category | 'SUCCESS') {
+  toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
     this.requestUpdate();
@@ -1008,7 +995,7 @@
     return html`(${filtered.length} of ${all.length})`;
   }
 
-  toggleExpanded(category: Category | 'SUCCESS') {
+  toggleExpanded(category: Category) {
     const expanded = this.isSectionExpanded.get(category);
     assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
@@ -1016,8 +1003,11 @@
     this.requestUpdate();
   }
 
-  computeRunResults(category: Category | 'SUCCESS', run: CheckRun) {
-    if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)];
+  computeRunResults(category: Category, run: CheckRun) {
+    const noResults = (run.results ?? []).length === 0;
+    if (noResults && category === Category.SUCCESS) {
+      return [this.computeSuccessfulRunResult(run)];
+    }
     return (
       run.results
         ?.filter(result => result.category === category)
@@ -1030,7 +1020,7 @@
   computeSuccessfulRunResult(run: CheckRun): RunResult {
     const adaptedRun: RunResult = {
       internalResultId: run.internalRunId + '-0',
-      category: Category.INFO, // will not be used, but is required
+      category: Category.SUCCESS,
       summary: run.statusDescription ?? '',
       ...run,
     };
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index de9c5fb..1d65546 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -20,12 +20,13 @@
 import {
   css,
   customElement,
-  internalProperty,
   property,
   PropertyValues,
   query,
+  state,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
+import './gr-checks-attempt';
 import {Action, Link, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
@@ -85,16 +86,6 @@
         .name {
           font-weight: var(--font-weight-bold);
         }
-        .attempt {
-          display: inline-block;
-          background-color: var(--tag-gray);
-          border-radius: var(--line-height-normal);
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          text-align: center;
-          vertical-align: top;
-          font-size: var(--font-size-small);
-        }
         .chip.error {
           border-left: var(--thick-border) solid var(--error-foreground);
         }
@@ -233,9 +224,7 @@
           <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
-          <span class="attempt" ?hidden="${this.run.isSingleAttempt}"
-            >${this.run.attempt}</span
-          >
+          <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
         </div>
         <div class="right">
           ${action
@@ -326,7 +315,7 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @internalProperty()
+  @state()
   filterRegExp = new RegExp('');
 
   @property()
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 418598b..b28596a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -15,13 +15,7 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {
-  css,
-  customElement,
-  internalProperty,
-  property,
-  PropertyValues,
-} from 'lit-element';
+import {css, customElement, property, PropertyValues, state} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Action} from '../../api/checks';
 import {
@@ -64,11 +58,11 @@
   @property()
   changeNum: NumericChangeId | undefined = undefined;
 
-  @internalProperty()
+  @state()
   selectedRuns: string[] = [];
 
   /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @internalProperty()
+  @state()
   selectedAttempts: Map<string, number | undefined> = new Map<
     string,
     number | undefined
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-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index fa81103..2f5eaf2 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -130,7 +130,6 @@
   name: string;
   query: string;
   suffixForDashboard?: string;
-  attentionSetOnly?: boolean;
   selfOnly?: boolean;
   hideIfEmpty?: boolean;
   assigneeOnly?: boolean;
@@ -165,7 +164,6 @@
   query: 'attention:${user}',
   hideIfEmpty: false,
   suffixForDashboard: 'limit:25',
-  attentionSetOnly: true,
 };
 const ASSIGNED: DashboardSection = {
   // Changes that are assigned to the viewed user.
@@ -359,6 +357,13 @@
   commit?: CommitId;
   options?: GenerateWebLinksOptions;
 }
+export interface GenerateWebLinksEditParameters {
+  type: WeblinkType.EDIT;
+  repo: RepoName;
+  commit: CommitId;
+  file: string;
+  options?: GenerateWebLinksOptions;
+}
 export interface GenerateWebLinksFileParameters {
   type: WeblinkType.FILE;
   repo: RepoName;
@@ -375,11 +380,13 @@
 
 export type GenerateWebLinksParameters =
   | GenerateWebLinksPatchsetParameters
+  | GenerateWebLinksEditParameters
   | GenerateWebLinksFileParameters
   | GenerateWebLinksChangeParameters;
 
 export type NavigateCallback = (target: string, redirect?: boolean) => void;
 export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
+// TODO: Refactor to return only GeneratedWebLink[]
 export type GenerateWebLinksCallback = (
   params: GenerateWebLinksParameters
 ) => GeneratedWebLink[] | GeneratedWebLink;
@@ -413,6 +420,7 @@
 
 export enum WeblinkType {
   CHANGE = 'change',
+  EDIT = 'edit',
   FILE = 'file',
   PATCHSET = 'patchset',
 }
@@ -889,6 +897,24 @@
     return this._getUrlFor({view: GerritView.SETTINGS});
   },
 
+  getEditWebLinks(
+    repo: RepoName,
+    commit: CommitId,
+    file: string,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksEditParameters = {
+      type: WeblinkType.EDIT,
+      repo,
+      commit,
+      file,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
   getFileWebLinks(
     repo: RepoName,
     commit: CommitId,
@@ -953,11 +979,8 @@
     title = '',
     config: UserDashboardConfig = {}
   ): UserDashboard {
-    const attentionEnabled =
-      config.change && !!config.change.enable_attention_set;
     const assigneeEnabled = config.change && !!config.change.enable_assignee;
     sections = sections
-      .filter(section => attentionEnabled || !section.attentionSetOnly)
       .filter(section => assigneeEnabled || !section.assigneeOnly)
       .filter(section => user === 'self' || !section.selfOnly)
       .map(section => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 4aefc16..27708ab 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -33,6 +33,7 @@
   GenerateUrlRepoViewParameters,
   GenerateUrlSearchViewParameters,
   GenerateWebLinksChangeParameters,
+  GenerateWebLinksEditParameters,
   GenerateWebLinksFileParameters,
   GenerateWebLinksParameters,
   GenerateWebLinksPatchsetParameters,
@@ -382,6 +383,8 @@
     params: GenerateWebLinksParameters
   ): GeneratedWebLink[] | GeneratedWebLink {
     switch (params.type) {
+      case WeblinkType.EDIT:
+        return this._getEditWebLinks(params);
       case WeblinkType.FILE:
         return this._getFileWebLinks(params);
       case WeblinkType.CHANGE:
@@ -457,6 +460,10 @@
     );
   }
 
+  _getEditWebLinks(params: GenerateWebLinksEditParameters): GeneratedWebLink[] {
+    return params.options?.weblinks || [];
+  }
+
   _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
     return params.options?.weblinks || [];
   }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 9c63f8b..a3ddff3 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -34,6 +34,7 @@
 import {CustomKeyboardEvent} from '../../../types/events';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {getKeyboardEvent} from '../../../utils/dom-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -385,7 +386,7 @@
   }
 
   _handleSearch(e: CustomKeyboardEvent) {
-    const keyboardEvent = this.getKeyboardEvent(e);
+    const keyboardEvent = getKeyboardEvent(e);
     if (
       this.shouldSuppressKeyboardShortcut(e) ||
       (this.modifierPressed(e) && !keyboardEvent.shiftKey)
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 0bf9f1c..5cd7bfc 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -40,6 +40,8 @@
 import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface GrApplyFixDialog {
   $: {
@@ -97,6 +99,12 @@
   })
   _disableApplyFixButton?: boolean;
 
+  layers = appContext.flagsService.isEnabled(
+    KnownExperimentId.TOKEN_HIGHLIGHTING
+  )
+    ? [new TokenHighlightLayer()]
+    : [];
+
   private refitOverlay?: () => void;
 
   private readonly restApiService = appContext.restApiService;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
index 52fa9841..b0716dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
@@ -31,10 +31,6 @@
       background-color: var(--background-color-secondary);
       border-bottom: 1px solid var(--border-color);
     }
-    .fixActions {
-      display: flex;
-      justify-content: flex-end;
-    }
     gr-button {
       margin-left: var(--spacing-m);
     }
@@ -65,6 +61,7 @@
               change-num="[[changeNum]]"
               path="[[item.filepath]]"
               diff="[[item.preview]]"
+              layers="[[layers]]"
             ></gr-diff>
           </div>
         </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 8cc7a3f..73afde8 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -51,6 +51,7 @@
 import {appContext} from '../../../services/app-context';
 import {CommentSide, Side} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
@@ -522,6 +523,33 @@
       .length;
   }
 
+  computeDraftCountForFile(patchRange?: PatchRange, file?: NormalizedFileInfo) {
+    if (patchRange === undefined || file === undefined) {
+      return 0;
+    }
+    const getCommentForPath = (path?: string) => {
+      if (!path) return 0;
+      return (
+        this.computeDraftCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        this.computeDraftCount({
+          patchNum: patchRange.patchNum,
+          path,
+        }) +
+        this.computePortedDraftCount(
+          {
+            patchNum: patchRange.patchNum,
+            basePatchNum: patchRange.basePatchNum,
+          },
+          path
+        )
+      );
+    };
+    return getCommentForPath(file.__path) + getCommentForPath(file.old_path);
+  }
+
   /**
    * @param includeUnmodified Included unmodified status of the file in the
    * comment string or not. For files we opt of chip instead of a string.
@@ -537,6 +565,14 @@
     if (!patchRange) return '';
 
     const threads = this.getThreadsBySideForFile({path}, patchRange);
+    if (changeFileInfo?.old_path) {
+      threads.push(
+        ...this.getThreadsBySideForFile(
+          {path: changeFileInfo.old_path},
+          patchRange
+        )
+      );
+    }
     const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
       .length;
     const unresolvedCount = threads.reduce((cnt, thread) => {
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
new file mode 100644
index 0000000..41a448e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,482 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip.js';
+
+import '../../shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {
+  css,
+  customElement,
+  html,
+  LitElement,
+  property,
+  TemplateResult,
+} from 'lit-element';
+
+import {
+  ContextButtonType,
+  RenderPreferences,
+  SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
+}
+
+@customElement('gr-context-controls')
+export class GrContextControls extends LitElement {
+  @property({type: Object}) renderPreferences?: RenderPreferences;
+
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: Object}) section?: HTMLElement;
+
+  @property({type: Object}) contextGroups: GrDiffGroup[] = [];
+
+  @property({type: Boolean}) showAbove = false;
+
+  @property({type: Boolean}) showBelow = false;
+
+  static styles = css`
+    :host {
+      display: flex;
+      width: 100%;
+      height: 100%;
+      justify-content: center;
+      position: absolute;
+    }
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+      /* All position is relative to container, so ignore sibling buttons. */
+      position: absolute;
+    }
+    .contextControlButton:first-child {
+      /* First button needs to claim width to display without text wrapping. */
+      position: relative;
+    }
+    .centeredButton {
+      /* Center over divider. */
+      top: 50%;
+      transform: translateY(-50%);
+    }
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+    }
+
+    .aboveButton {
+      /* Display over preceding content / background placeholder. */
+      transform: translateY(-100%);
+    }
+    .belowButton {
+      top: calc(100% + var(--divider-border));
+    }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
+  `;
+
+  // To pass CSS mixins for @apply to Polymer components, they need to be
+  // wrapped in a <custom-style>.
+  static customStyles = html`
+    <custom-style>
+      <style>
+        .centeredButton {
+          --gr-button: {
+            color: var(--diff-context-control-color);
+            border-style: solid;
+            border-color: var(--border-color);
+            border-top-width: 1px;
+            border-right-width: 1px;
+            border-bottom-width: 1px;
+            border-left-width: 1px;
+
+            border-top-left-radius: var(--border-radius);
+            border-top-right-radius: var(--border-radius);
+            border-bottom-right-radius: var(--border-radius);
+            border-bottom-left-radius: var(--border-radius);
+            padding: var(--spacing-s) var(--spacing-l);
+          }
+        }
+        .aboveButton {
+          --gr-button: {
+            color: var(--diff-context-control-color);
+            border-style: solid;
+            border-color: var(--border-color);
+            border-top-width: 1px;
+            border-right-width: 1px;
+            border-bottom-width: 0;
+            border-left-width: 1px;
+
+            border-top-left-radius: var(--border-radius);
+            border-top-right-radius: var(--border-radius);
+            border-bottom-right-radius: 0;
+            border-bottom-left-radius: var(--border-radius);
+            padding: var(--spacing-xxs) var(--spacing-l);
+          }
+        }
+        .belowButton {
+          --gr-button: {
+            color: var(--diff-context-control-color);
+            border-style: solid;
+            border-color: var(--border-color);
+            border-top-width: 0;
+            border-right-width: 1px;
+            border-bottom-width: 1px;
+            border-left-width: 1px;
+
+            border-top-left-radius: 0;
+            border-top-right-radius: 0;
+            border-bottom-right-radius: var(--border-radius);
+            border-bottom-left-radius: var(--border-radius);
+            padding: var(--spacing-xxs) var(--spacing-l);
+          }
+        }
+      </style>
+    </custom-style>
+  `;
+
+  private numLines() {
+    const {leftStart, leftEnd} = this.contextRange();
+    return leftEnd - leftStart + 1;
+  }
+
+  private createExpandAllButtonContainer() {
+    return html` <div
+      class="style-scope gr-diff aboveBelowButtons fullExpansion"
+    >
+      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+    </div>`;
+  }
+
+  /**
+   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+   */
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltip?: TemplateResult
+  ) {
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let ariaLabel = '';
+    let classes = 'contextControlButton showContext ';
+
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
+      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      classes +=
+        this.showAbove && this.showBelow
+          ? 'centeredButton'
+          : this.showAbove
+          ? 'aboveButton'
+          : 'belowButton';
+      if (this.partialContent) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...this.contextGroups);
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = `+${linesToExpand}`;
+      classes += 'aboveButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = `+${linesToExpand}`;
+      classes += 'belowButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = '+Block';
+      classes += 'aboveButton';
+      ariaLabel = 'Show block above';
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = '+Block';
+      classes += 'belowButton';
+      ariaLabel = 'Show block below';
+    }
+    const expandHandler = this.createExpansionHandler(
+      linesToExpand,
+      type,
+      groups
+    );
+
+    const button = html` <gr-button
+      class="${classes}"
+      link="true"
+      no-uppercase="true"
+      aria-label="${ariaLabel}"
+      @click="${expandHandler}"
+    >
+      <span class="showContext">${text}</span>
+      ${tooltip}
+    </gr-button>`;
+    return button;
+  }
+
+  private createExpansionHandler(
+    linesToExpand: number,
+    type: ContextButtonType,
+    groups: GrDiffGroup[]
+  ) {
+    return (e: Event) => {
+      e.stopPropagation();
+      if (type === ContextButtonType.ALL && this.partialContent) {
+        const {leftStart, leftEnd, rightStart, rightEnd} = this.contextRange();
+        const lineRange = {
+          left: {
+            start_line: leftStart,
+            end_line: leftEnd,
+          },
+          right: {
+            start_line: rightStart,
+            end_line: rightEnd,
+          },
+        };
+        fire(this, 'content-load-needed', {
+          lineRange,
+        });
+      } else {
+        assertIsDefined(this.section, 'section');
+        fire(this, 'diff-context-expanded', {
+          groups,
+          section: this.section!,
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+      }
+    };
+  }
+
+  private showPartialLinks() {
+    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+  }
+
+  /**
+   * Creates a container div with partial (+10) expansion buttons (above and/or below).
+   */
+  private createPartialExpansionButtons() {
+    if (!this.showPartialLinks()) {
+      return undefined;
+    }
+    let aboveButton;
+    let belowButton;
+    if (this.showAbove) {
+      aboveButton = this.createContextButton(
+        ContextButtonType.ABOVE,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (this.showBelow) {
+      belowButton = this.createContextButton(
+        ContextButtonType.BELOW,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    return aboveButton || belowButton
+      ? html` <div class="aboveBelowButtons partialExpansion">
+          ${aboveButton} ${belowButton}
+        </div>`
+      : undefined;
+  }
+
+  /**
+   * Checks if the collapsed section contains unavailable content (skip chunks).
+   */
+  private get partialContent() {
+    return this.contextGroups.some(c => !!c.skip);
+  }
+
+  /**
+   * Creates a container div with block expansion buttons (above and/or below).
+   */
+  private createBlockExpansionButtons() {
+    if (
+      !this.showPartialLinks() ||
+      !this.renderPreferences?.use_block_expansion ||
+      this.partialContent
+    ) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    if (this.showAbove) {
+      aboveBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_ABOVE,
+        this.numLines(),
+        this.contextRange().rightStart - 1
+      );
+    }
+    if (this.showBelow) {
+      belowBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_BELOW,
+        this.numLines(),
+        this.contextRange().rightEnd + 1
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      return html` <div class="aboveBelowButtons blockExpansion">
+        ${aboveBlockButton} ${belowBlockButton}
+      </div>`;
+    }
+    return undefined;
+  }
+
+  private createBlockButtonTooltip(
+    buttonType: ContextButtonType,
+    syntaxPath: SyntaxBlock[],
+    linesToExpand: number
+  ) {
+    // Create breadcrumb string:
+    // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+    const tooltipText = syntaxPath.length
+      ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
+      : `${linesToExpand} common lines`;
+
+    const position =
+      buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
+    return html`<paper-tooltip offset="10" position="${position}"
+      ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+    >`;
+  }
+
+  private createBlockButton(
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number
+  ) {
+    assertIsDefined(this.diff, 'diff');
+    const syntaxTree = this.diff!.meta_b.syntax_tree;
+    const outlineSyntaxPath = findBlockTreePathForLine(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    const tooltip = this.createBlockButtonTooltip(
+      buttonType,
+      outlineSyntaxPath,
+      linesToExpand
+    );
+    return this.createContextButton(buttonType, linesToExpand, tooltip);
+  }
+
+  private contextRange() {
+    return {
+      leftStart: this.contextGroups[0].lineRange.left.start_line,
+      leftEnd: this.contextGroups[this.contextGroups.length - 1].lineRange.left
+        .end_line,
+      rightStart: this.contextGroups[0].lineRange.right.start_line,
+      rightEnd: this.contextGroups[this.contextGroups.length - 1].lineRange
+        .right.end_line,
+    };
+  }
+
+  private hasValidProperties() {
+    return !!(this.diff && this.section && this.contextGroups?.length);
+  }
+
+  render() {
+    if (!this.hasValidProperties()) {
+      console.error('Invalid properties for gr-context-controls!');
+      return html`<p>invalid properties</p>`;
+    }
+    return html`
+      ${GrContextControls.customStyles} ${this.createExpandAllButtonContainer()}
+      ${this.createPartialExpansionButtons()}
+      ${this.createBlockExpansionButtons()}
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls': GrContextControls;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..f59b19d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,386 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-context-control tests', () => {
+  let element: GrContextControls;
+
+  setup(async () => {
+    element = document.createElement('gr-context-controls');
+    element.diff = ({content: []} as any) as DiffInfo;
+    element.renderPreferences = {};
+    element.section = document.createElement('div');
+    blankFixture.instantiate().appendChild(element);
+    await flush();
+  });
+
+  function createContextGroups(options: {offset?: number; count?: number}) {
+    const offset = options.offset || 0;
+    const numLines = options.count || 10;
+    const lines = [];
+    for (let i = 0; i < numLines; i++) {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = offset + i + 1;
+      line.afterNumber = offset + i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+
+    return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
+  }
+
+  test('no +10 buttons for 10 or less lines', async () => {
+    element.contextGroups = createContextGroups({count: 10});
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+  });
+
+  test('context control at the top', async () => {
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showBelow = true;
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'belowButton');
+    assert.include([...buttons[1].classList.values()], 'belowButton');
+  });
+
+  test('context control in the middle', async () => {
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+
+    assert.equal(buttons.length, 3);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+    assert.equal(buttons[2].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'centeredButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+    assert.include([...buttons[2].classList.values()], 'belowButton');
+  });
+
+  test('context control at the bottom', async () => {
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showAbove = true;
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'aboveButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+  });
+
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+    element.renderPreferences!.use_block_expansion = true;
+    element.diff!.meta_b = ({
+      syntax_tree: syntaxTree,
+    } as any) as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showBelow = true;
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion gr-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion gr-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion in the middle', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion gr-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion gr-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 2);
+    assert.equal(blockExpansionButtons.length, 2);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+    assert.include(
+      [...blockExpansionButtons[1].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion at the bottom', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showAbove = true;
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion gr-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion gr-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+  });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    const tooltipAbove = blockExpansionButtons[0].querySelector(
+      'paper-tooltip'
+    )!;
+    const tooltipBelow = blockExpansionButtons[1].querySelector(
+      'paper-tooltip'
+    )!;
+    assert.equal(
+      tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(
+      tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(tooltipAbove!.getAttribute('position'), 'top');
+    assert.equal(tooltipBelow!.getAttribute('position'), 'bottom');
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index 76ebc50..d65deb6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -46,7 +46,7 @@
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
-import {getLineNumber} from '../gr-diff/gr-diff-utils';
+import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -257,26 +257,6 @@
     this._layers = layers;
   }
 
-  getLineElByChild(node?: Node): HTMLElement | null {
-    while (node) {
-      if (node instanceof Element) {
-        if (node.classList.contains('lineNum')) {
-          return node as HTMLElement;
-        }
-        if (node.classList.contains('section')) {
-          return null;
-        }
-      }
-      node = node.previousSibling ?? node.parentElement ?? undefined;
-    }
-    return null;
-  }
-
-  getLineNumberByChild(node: Node) {
-    const lineEl = this.getLineElByChild(node);
-    return getLineNumber(lineEl);
-  }
-
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
     if (!this._builder) return null;
     return this._builder.getContentTdByLine(lineNumber, side, root);
@@ -293,7 +273,7 @@
     if (!lineEl) return null;
     const line = getLineNumber(lineEl);
     if (!line) return null;
-    const side = this.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
     const row = this._getDiffRowByChild(lineEl);
@@ -307,10 +287,6 @@
     );
   }
 
-  getSideByLineEl(lineEl: Element) {
-    return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
-  }
-
   emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
     if (!this._builder) return;
     this._builder.emitGroup(group, sectionEl);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 7c01a95..4455bd5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff-group.js';
 import './gr-diff-builder.js';
+import '../gr-context-controls/gr-context-controls.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -74,83 +75,6 @@
     assert.isTrue(node.classList.contains('classes'));
   });
 
-  suite('context control', () => {
-    function createContextGroups(options) {
-      const offset = options.offset || 0;
-      const numLines = options.count || 10;
-      const lines = [];
-      for (let i = 0; i < numLines; i++) {
-        const line = new GrDiffLine(GrDiffLineType.BOTH);
-        line.beforeNumber = offset + i + 1;
-        line.afterNumber = offset + i + 1;
-        line.text = 'lorem upsum';
-        lines.push(line);
-      }
-
-      return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
-    }
-
-    function createContextSectionForGroups(options) {
-      const section = document.createElement('div');
-      builder._createContextControls(
-          section, createContextGroups(options), DiffViewMode.UNIFIED);
-      return section;
-    }
-
-    setup(() => {
-      builder = new GrDiffBuilder({content: []}, prefs, null, []);
-    });
-
-    test('no +10 buttons for 10 or less lines', () => {
-      const section = createContextSectionForGroups({count: 10});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 1);
-      assert.equal(buttons[0].textContent, '+10 common lines');
-    });
-
-    test('context control at the top', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 0, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'belowButton');
-      assert.include([...buttons[1].classList.values()], 'belowButton');
-    });
-
-    test('context control in the middle', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 10, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 3);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-      assert.equal(buttons[2].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'centeredButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-      assert.include([...buttons[2].classList.values()], 'belowButton');
-    });
-
-    test('context control at the bottom', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 30, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'aboveButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-    });
-  });
-
   test('newlines 1', () => {
     let text = 'abcdef';
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index e927fdf..2067455 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -122,7 +122,14 @@
       Side.RIGHT
     );
     row.appendChild(lineNumberEl);
-    row.appendChild(this._createTextEl(lineNumberEl, line));
+    let side = undefined;
+    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+      side = Side.RIGHT;
+    }
+    if (line.type === GrDiffLineType.REMOVE) {
+      side = Side.LEFT;
+    }
+    row.appendChild(this._createTextEl(lineNumberEl, line, side));
     return row;
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index eb943f7..0ccf8bc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -16,22 +16,20 @@
  */
 import {
   ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+
+import '../gr-context-controls/gr-context-controls';
+import {GrContextControls} from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
-import {pluralize} from '../../../utils/string-util';
-import {fire} from '../../../utils/event-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -55,15 +53,8 @@
  */
 const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-const PARTIAL_CONTEXT_AMOUNT = 10;
-
-enum ContextButtonType {
-  ABOVE = 'above',
-  BELOW = 'below',
-  ALL = 'all',
-}
-
-export interface DiffContextExpandedEventDetail {
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
   groups: GrDiffGroup[];
   section: HTMLElement;
   numLines: number;
@@ -158,13 +149,6 @@
     REMOVED: 'edit_a',
   };
 
-  // TODO(TS): Replace usages with ContextButtonType enum.
-  static readonly ContextButtonType = {
-    ABOVE: 'above',
-    BELOW: 'below',
-    ALL: 'all',
-  };
-
   abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
 
   abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
@@ -324,10 +308,6 @@
     const leftStart = contextGroups[0].lineRange.left.start_line;
     const leftEnd =
       contextGroups[contextGroups.length - 1].lineRange.left.end_line;
-    const numLines = leftEnd - leftStart + 1;
-
-    if (numLines === 0) console.error('context group without lines');
-
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
@@ -344,9 +324,7 @@
         section,
         contextGroups,
         showAbove,
-        showBelow,
-        numLines,
-        viewMode
+        showBelow
       )
     );
     if (showBelow) {
@@ -364,78 +342,26 @@
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
-    showBelow: boolean,
-    numLines: number,
-    viewMode: DiffViewMode
+    showBelow: boolean
   ): HTMLElement {
-    const row = this._createElement('tr', 'dividerRow');
-    if (showAbove && !showBelow) {
-      row.classList.add('showAboveOnly');
-    } else if (!showAbove && showBelow) {
-      row.classList.add('showBelowOnly');
-    } else {
-      // Note that !showAbove && !showBelow also intentionally creates
-      // "showBoth". This means the file is completely collapsed, which is
-      // unusual, but at least happens in one test.
-      row.classList.add('showBoth');
+    const row = this._createElement('tr', 'contextDivider');
+    if (!(showAbove && showBelow)) {
+      row.classList.add('collapsed');
     }
 
-    row.appendChild(this._createBlameCell(0));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(this._createElement('td'));
-    }
+    const element = this._createElement('td', 'dividerCell');
+    row.appendChild(element);
 
-    const cell = this._createElement('td', 'dividerCell');
-    cell.setAttribute('colspan', '3');
-    row.appendChild(cell);
-    const verticalFlex = this._createElement('div', 'verticalFlex');
-    cell.appendChild(verticalFlex);
-    const horizontalFlex = this._createElement('div', 'horizontalFlex');
-    verticalFlex.appendChild(horizontalFlex);
-
-    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
-    horizontalFlex.appendChild(showAllContainer);
-    const showAllButton = this._createContextButton(
-      ContextButtonType.ALL,
-      section,
-      contextGroups,
-      numLines
-    );
-    showAllButton.classList.add(
-      showAbove && showBelow
-        ? 'centeredButton'
-        : showAbove
-        ? 'aboveButton'
-        : 'belowButton'
-    );
-    showAllContainer.appendChild(showAllButton);
-
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-    if (showPartialLinks) {
-      const container = this._createElement('div', 'aboveBelowButtons');
-      if (showAbove) {
-        container.appendChild(
-          this._createContextButton(
-            ContextButtonType.ABOVE,
-            section,
-            contextGroups,
-            numLines
-          )
-        );
-      }
-      if (showBelow) {
-        container.appendChild(
-          this._createContextButton(
-            ContextButtonType.BELOW,
-            section,
-            contextGroups,
-            numLines
-          )
-        );
-      }
-      horizontalFlex.appendChild(container);
-    }
-
+    const contextControls = this._createElement(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this._renderPrefs;
+    contextControls.section = section;
+    contextControls.contextGroups = contextGroups;
+    contextControls.showAbove = showAbove;
+    contextControls.showBelow = showBelow;
+    element.appendChild(contextControls);
     return row;
   }
 
@@ -467,87 +393,6 @@
     return row;
   }
 
-  _createContextButton(
-    type: ContextButtonType,
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    numLines: number
-  ) {
-    const context = PARTIAL_CONTEXT_AMOUNT;
-    const button = this._createElement('gr-button', 'showContext');
-    button.classList.add('contextControlButton');
-    button.setAttribute('link', 'true');
-    button.setAttribute('no-uppercase', 'true');
-
-    let text = '';
-    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
-    let requiresLoad = false;
-    if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      text = `+${pluralize(numLines, 'common line')}`;
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(numLines, 'common line')}`
-      );
-      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
-      if (requiresLoad) {
-        // Expanding content would require load of more data
-        text += ' (too large)';
-      }
-      groups.push(...contextGroups);
-    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      groups = hideInContextControl(contextGroups, context, numLines);
-      text = `+${context}`;
-      button.classList.add('aboveButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(context, 'line')} above`
-      );
-    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
-      groups = hideInContextControl(contextGroups, 0, numLines - context);
-      text = `+${context}`;
-      button.classList.add('belowButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(context, 'line')} below`
-      );
-    }
-    const textSpan = this._createElement('span', 'showContext');
-    textSpan.textContent = text;
-    button.appendChild(textSpan);
-
-    if (requiresLoad) {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        const firstRange = groups[0].lineRange;
-        const lastRange = groups[groups.length - 1].lineRange;
-        const lineRange = {
-          left: {
-            start_line: firstRange.left.start_line,
-            end_line: lastRange.left.end_line,
-          },
-          right: {
-            start_line: firstRange.right.start_line,
-            end_line: lastRange.right.end_line,
-          },
-        };
-        fire(button, 'content-load-needed', {
-          lineRange,
-        });
-      });
-    } else {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        fire(button, 'diff-context-expanded', {
-          groups,
-          section,
-          numLines,
-        });
-      });
-    }
-
-    return button;
-  }
-
   _createLineEl(
     line: GrDiffLine,
     number: LineNumber,
@@ -630,14 +475,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-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 75439c8..901f72a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -617,6 +617,7 @@
   test('expand context updates stops', done => {
     sinon.spy(cursorElement, '_updateStops');
     MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('gr-context-controls').shadowRoot
         .querySelector('.showContext'));
     flush(() => {
       assert.isTrue(cursorElement._updateStops.called);
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..09e9d7c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -18,7 +18,10 @@
 import '../gr-diff/gr-diff';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  GerritNav,
+  GeneratedWebLink,
+} from '../../core/gr-navigation/gr-navigation';
 import {
   getLine,
   getRange,
@@ -80,6 +83,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.';
@@ -187,6 +191,9 @@
   commitRange?: CommitRange;
 
   @property({type: Object, notify: true})
+  editWeblinks?: GeneratedWebLink[];
+
+  @property({type: Object, notify: true})
   filesWeblinks: FilesWebLinks | {} = {};
 
   @property({type: Boolean, reflectToAttribute: true})
@@ -371,6 +378,7 @@
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
 
+      this.editWeblinks = this._getEditWeblinks(diff);
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
       const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
@@ -400,8 +408,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() {
@@ -478,6 +494,16 @@
       });
   }
 
+  _getEditWeblinks(diff: DiffInfo) {
+    if (!this.projectName || !this.commitRange || !this.path) return undefined;
+    return GerritNav.getEditWebLinks(
+      this.projectName,
+      this.commitRange.baseCommit,
+      this.path,
+      {weblinks: diff?.edit_web_links}
+    );
+  }
+
   _getFilesWeblinks(diff: DiffInfo) {
     if (!this.projectName || !this.commitRange || !this.path) return {};
     return {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 5e1b5ff..6c96e34 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -211,7 +211,7 @@
     assert.isTrue(cancelStub.called);
   });
 
-  test('reload() loads files weblinks', () => {
+  test('reload() loads files weblinks', async () => {
     element.change = createChange();
     const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
         .returns({name: 'stubb', url: '#s'});
@@ -222,28 +222,40 @@
     element.path = 'test-path';
     element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
     element.patchRange = {};
-    return element.reload().then(() => {
-      assert.isTrue(weblinksStub.calledTwice);
-      assert.isTrue(weblinksStub.firstCall.calledWith({
-        commit: 'test-base',
-        file: 'test-path',
-        options: {
-          weblinks: undefined,
-        },
-        repo: 'test-project',
-        type: GerritNav.WeblinkType.FILE}));
-      assert.isTrue(weblinksStub.secondCall.calledWith({
-        commit: 'test-commit',
-        file: 'test-path',
-        options: {
-          weblinks: undefined,
-        },
-        repo: 'test-project',
-        type: GerritNav.WeblinkType.FILE}));
-      assert.deepEqual(element.filesWeblinks, {
-        meta_a: [{name: 'stubb', url: '#s'}],
-        meta_b: [{name: 'stubb', url: '#s'}],
-      });
+
+    await element.reload();
+
+    assert.equal(weblinksStub.callCount, 3);
+    assert.deepEqual(weblinksStub.firstCall.args[0], {
+      commit: 'test-base',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.EDIT});
+    assert.deepEqual(element.editWeblinks, [{
+      name: 'stubb', url: '#s',
+    }]);
+    assert.deepEqual(weblinksStub.secondCall.args[0], {
+      commit: 'test-base',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.FILE});
+    assert.deepEqual(weblinksStub.thirdCall.args[0], {
+      commit: 'test-commit',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.FILE});
+    assert.deepEqual(element.filesWeblinks, {
+      meta_a: [{name: 'stubb', url: '#s'}],
+      meta_b: [{name: 'stubb', url: '#s'}],
     });
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 77fe299..9a98a21 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -18,6 +18,8 @@
 import '@polymer/paper-card/paper-card';
 import '@polymer/paper-checkbox/paper-checkbox';
 import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
 import '@polymer/paper-item/paper-item';
 import '@polymer/paper-listbox/paper-listbox';
 import './gr-overview-image';
@@ -27,19 +29,30 @@
   css,
   customElement,
   html,
-  internalProperty,
   LitElement,
   property,
   PropertyValues,
   query,
+  state,
 } from 'lit-element';
 import {classMap} from 'lit-html/directives/class-map';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
 
-import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {
+  createEvent,
+  Dimensions,
+  fitToFrame,
+  FrameConstrainer,
+  Point,
+  Rect,
+} from './util';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
+const DEFAULT_AUTOMATIC_BLINK_TIME_MS = 1000;
+
+const AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS = 350;
+
 /**
  * This components allows the user to rapidly switch between two given images
  * rendered in the same location, to make subtle differences more noticeable.
@@ -53,17 +66,23 @@
   // URL for the image to use as revision.
   @property({type: String}) revisionUrl = '';
 
-  @internalProperty() protected baseSelected = true;
+  @state() protected baseSelected = true;
 
-  @internalProperty() protected scaledSelected = true;
+  @state() protected scaledSelected = true;
 
-  @internalProperty() protected followMouse = false;
+  @state() protected followMouse = false;
 
-  @internalProperty() protected scale = 1;
+  @state() protected scale = 1;
 
-  @internalProperty() protected checkerboardSelected = true;
+  @state() protected checkerboardSelected = true;
 
-  @internalProperty() protected zoomedImageStyle: StyleInfo = {};
+  @state() protected backgroundColor = '';
+
+  @state() protected automaticBlink = false;
+
+  @state() protected automaticBlinkShown = false;
+
+  @state() protected zoomedImageStyle: StyleInfo = {};
 
   @query('.imageArea') protected imageArea!: HTMLDivElement;
 
@@ -71,18 +90,20 @@
 
   @query('#source-image') protected sourceImage!: HTMLImageElement;
 
+  @query('#automatic-blink-button') protected automaticBlinkButton?: Element;
+
   private imageSize: Dimensions = {width: 0, height: 0};
 
-  @internalProperty()
+  @state()
   protected magnifierSize: Dimensions = {width: 0, height: 0};
 
-  @internalProperty()
+  @state()
   protected magnifierFrame: Rect = {
     origin: {x: 0, y: 0},
     dimensions: {width: 0, height: 0},
   };
 
-  @internalProperty()
+  @state()
   protected overviewFrame: Rect = {
     origin: {x: 0, y: 0},
     dimensions: {width: 0, height: 0},
@@ -97,7 +118,7 @@
     2,
   ];
 
-  @internalProperty() protected grabbing = false;
+  @state() protected grabbing = false;
 
   private ownsMouseDown = false;
 
@@ -120,12 +141,27 @@
     }
   );
 
+  // Ensure constant function references, so that render() does not bind a new
+  // event listener on every call, as it would with lambdas.
+  private createColorPickerCallback(color: string) {
+    return {color, callback: () => this.pickColor(color)};
+  }
+
+  private readonly colorPickerCallbacks = [
+    this.createColorPickerCallback('#fff'),
+    this.createColorPickerCallback('#000'),
+    this.createColorPickerCallback('#aaa'),
+  ];
+
+  private automaticBlinkTimer?: ReturnType<typeof setInterval>;
+
   static styles = css`
     :host {
       display: flex;
       width: 100%;
       height: 100%;
       box-sizing: border-box;
+      text-align: initial !important;
       font-size: var(--font-size-normal);
       --image-border-width: 2px;
     }
@@ -139,6 +175,7 @@
       margin: var(--spacing-m);
       padding: var(--image-border-width);
       max-height: 100%;
+      position: relative;
     }
     #spacer {
       visibility: hidden;
@@ -157,6 +194,21 @@
     gr-zoomed-image.revision {
       border-color: var(--revision-image-border-color, rgb(170, 242, 170));
     }
+    #automatic-blink-button {
+      position: absolute;
+      right: var(--spacing-xl);
+      bottom: var(--spacing-xl);
+      opacity: 0;
+      transition: opacity 200ms ease;
+      --paper-fab-background: var(--primary-button-background-color);
+      --paper-fab-keyboard-focus-background: var(
+        --primary-button-background-color
+      );
+    }
+    #automatic-blink-button.show,
+    #automatic-blink-button:focus-visible {
+      opacity: 1;
+    }
     .checkerboard {
       --square-size: var(--checkerboard-square-size, 10px);
       --square-color: var(--checkerboard-square-color, #808080);
@@ -186,6 +238,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.manualBlink}">
+        </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,34 +621,116 @@
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'base'})
+    );
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'revision'})
+    );
   }
 
-  toggleImage() {
+  manualBlink() {
+    this.toggleImage();
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'switch'})
+    );
+  }
+
+  private toggleImage() {
     if (this.baseUrl && this.revisionUrl) {
       this.baseSelected = !this.baseSelected;
     }
   }
 
+  toggleAutomaticBlink() {
+    this.automaticBlink = !this.automaticBlink;
+    if (this.automaticBlink) {
+      this.toggleImage();
+      this.setBlinkInterval();
+    } else {
+      if (this.automaticBlinkTimer) {
+        clearInterval(this.automaticBlinkTimer);
+        this.automaticBlinkTimer = undefined;
+      }
+    }
+    this.dispatchEvent(
+      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
+    );
+  }
+
+  private setBlinkInterval() {
+    if (this.automaticBlinkTimer) {
+      clearInterval(this.automaticBlinkTimer);
+    }
+    this.automaticBlinkTimer = setInterval(() => {
+      this.toggleImage();
+    }, DEFAULT_AUTOMATIC_BLINK_TIME_MS);
+  }
+
   zoomControlChanged(event: CustomEvent) {
     const value = event.detail.value;
     if (!value) return;
     if (value === 'fit') {
       this.scaledSelected = true;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: 'fit'})
+      );
     }
     if (value > 0) {
       this.scaledSelected = false;
       this.scale = value;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: value})
+      );
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
+    this.dispatchEvent(
+      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
+    );
+  }
+
+  pickColor(value: string) {
+    this.checkerboardSelected = false;
+    this.backgroundColor = value;
+    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+  }
+
+  pickCheckerboard() {
+    this.checkerboardSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'background-color-changed', value: 'checkerboard'})
+    );
+  }
+
+  mousemoveImageArea(event: MouseEvent) {
+    if (this.automaticBlinkButton) {
+      this.updateAutomaticBlinkVisibility(event);
+    }
+    this.mousemoveMagnifier(event);
+  }
+
+  private updateAutomaticBlinkVisibility(event: MouseEvent) {
+    const rect = this.automaticBlinkButton!.getBoundingClientRect();
+    const centerX = rect.left + (rect.right - rect.left) / 2;
+    const centerY = rect.top + (rect.bottom - rect.top) / 2;
+    const distX = Math.abs(centerX - event.clientX);
+    const distY = Math.abs(centerY - event.clientY);
+    this.automaticBlinkShown =
+      distX < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS &&
+      distY < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS;
+  }
+
+  mouseleaveImageArea() {
+    this.automaticBlinkShown = false;
   }
 
   mousedownMagnifier(event: MouseEvent) {
@@ -481,16 +745,20 @@
   }
 
   mouseupMagnifier(event: MouseEvent) {
+    if (!this.ownsMouseDown) return;
+    this.grabbing = false;
+    this.ownsMouseDown = false;
     const offsetX = event.clientX - this.pointerOnDown.x;
     const offsetY = event.clientY - this.pointerOnDown.y;
     const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
     // Consider very short drags as clicks. These tend to happen more often on
     // external mice.
-    if (this.ownsMouseDown && distance < DRAG_DEAD_ZONE_PIXELS) {
+    if (distance < DRAG_DEAD_ZONE_PIXELS) {
       this.toggleImage();
+      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+    } else {
+      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
     }
-    this.grabbing = false;
-    this.ownsMouseDown = false;
   }
 
   mousemoveMagnifier(event: MouseEvent) {
@@ -529,8 +797,10 @@
   }
 
   mouseleaveMagnifier() {
+    if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
+    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
   }
 
   dragstartMagnifier(event: DragEvent) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
index 55f83d6..9439dca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -18,15 +18,16 @@
   css,
   customElement,
   html,
-  internalProperty,
   LitElement,
   property,
   PropertyValues,
   query,
+  state,
 } from 'lit-element';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {ImageDiffAction} from '../../../api/diff';
 
-import {Dimensions, fitToFrame, Point, Rect} from './util';
+import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -44,15 +45,13 @@
   @property({type: Object})
   frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
 
-  @internalProperty() protected contentStyle: StyleInfo = {};
+  @state() protected contentStyle: StyleInfo = {};
 
-  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+  @state() protected contentTransformStyle: StyleInfo = {};
 
-  @internalProperty() protected frameStyle: StyleInfo = {};
+  @state() protected frameStyle: StyleInfo = {};
 
-  @internalProperty() protected overlayStyle: StyleInfo = {};
-
-  @internalProperty() protected dragging = false;
+  @state() protected dragging = false;
 
   @query('.content-box') protected contentBox!: HTMLDivElement;
 
@@ -62,6 +61,8 @@
 
   @query('.frame') protected frame!: HTMLDivElement;
 
+  protected overlay?: HTMLDivElement;
+
   private contentBounds: Dimensions = {width: 0, height: 0};
 
   private imageBounds: Dimensions = {width: 0, height: 0};
@@ -124,11 +125,6 @@
       position: absolute;
       will-change: transform;
     }
-    .overlay {
-      position: absolute;
-      z-index: 10000;
-      cursor: grabbing;
-    }
   `;
 
   render() {
@@ -158,20 +154,54 @@
             @mousedown="${this.grabFrame}"
           ></div>
         </div>
-        <div
-          class="overlay"
-          style="${styleMap({
-            ...this.overlayStyle,
-            display: this.dragging ? 'block' : 'none',
-          })}"
-          @mousemove="${this.overlayMouseMove}"
-          @mouseleave="${this.releaseFrame}"
-          @mouseup="${this.releaseFrame}"
-        ></div>
       </div>
     `;
   }
 
+  connectedCallback() {
+    super.connectedCallback();
+    if (this.isConnected) {
+      this.overlay = document.createElement('div');
+      // The overlay is added directly to document body to ensure it fills the
+      // entire screen to capture events, without being clipped by any parent
+      // overflow properties. This means it has to be styled manually, since
+      // component styles will not affect it.
+      this.overlay.style.position = 'fixed';
+      this.overlay.style.top = '0';
+      this.overlay.style.left = '0';
+      // We subtract 20 pixels in each dimension to prevent the overlay from
+      // extending offscreen under any existing scrollbar and causing the
+      // scrollbar for the other dimension to show up unnecessarily.
+      this.overlay.style.width = 'calc(100vw - 20px)';
+      this.overlay.style.height = 'calc(100vh - 20px)';
+      this.overlay.style.zIndex = '10000';
+      this.overlay.style.display = 'none';
+
+      this.overlay.addEventListener('mousemove', (event: MouseEvent) =>
+        this.maybeDragFrame(event)
+      );
+      this.overlay.addEventListener('mouseleave', (event: MouseEvent) => {
+        // Ignore mouseleave events that are due to closeOverlay() calls.
+        if (this.overlay?.style.display !== 'none') {
+          this.releaseFrame(event);
+        }
+      });
+      this.overlay.addEventListener('mouseup', (event: MouseEvent) =>
+        this.releaseFrame(event)
+      );
+
+      document.body.appendChild(this.overlay);
+    }
+  }
+
+  disconnectedCallback() {
+    if (this.overlay) {
+      document.body.removeChild(this.overlay);
+      this.overlay = undefined;
+    }
+    super.disconnectedCallback();
+  }
+
   firstUpdated() {
     this.resizeObserver.observe(this.contentBox);
     this.resizeObserver.observe(this.contentTransform);
@@ -187,9 +217,9 @@
     if (event.buttons !== 1) return;
     event.preventDefault();
 
-    this.updateOverlaySize();
-
     this.dragging = true;
+    this.openOverlay();
+
     const rect = this.content.getBoundingClientRect();
     this.notifyNewCenter({
       x: (event.clientX - rect.left) / this.scale,
@@ -203,9 +233,9 @@
     // Do not bubble up into clickOverview().
     event.stopPropagation();
 
-    this.updateOverlaySize();
-
     this.dragging = true;
+    this.openOverlay();
+
     const rect = this.frame.getBoundingClientRect();
     const frameCenterX = rect.x + rect.width / 2;
     const frameCenterY = rect.y + rect.height / 2;
@@ -228,13 +258,29 @@
 
   releaseFrame(event: MouseEvent) {
     event.preventDefault();
+
+    const detail: ImageDiffAction = {
+      type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
+    };
+    this.dispatchEvent(createEvent(detail));
+
     this.dragging = false;
+    this.closeOverlay();
     this.grabOffset = {x: 0, y: 0};
   }
 
-  overlayMouseMove(event: MouseEvent) {
-    event.preventDefault();
-    this.maybeDragFrame(event);
+  private openOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'block';
+      this.overlay.style.cursor = 'grabbing';
+    }
+  }
+
+  private closeOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'none';
+      this.overlay.style.cursor = '';
+    }
   }
 
   private updateScale() {
@@ -269,25 +315,6 @@
     };
   }
 
-  private updateOverlaySize() {
-    const rect = this.contentBox.getBoundingClientRect();
-    // Create a whole-page overlay to capture mouse events, so that the drag
-    // interaction continues until the user releases the mouse button. Since
-    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
-    // to prevent the overlay from extending offscreen under any existing
-    // scrollbar and causing the scrollbar for the other dimension to show up
-    // unnecessarily.
-    const width = window.innerWidth - 20;
-    const height = window.innerHeight - 20;
-    this.overlayStyle = {
-      ...this.overlayStyle,
-      top: `-${rect.top + 1}px`,
-      left: `-${rect.left + 1}px`,
-      width: `${width}px`,
-      height: `${height}px`,
-    };
-  }
-
   private notifyNewCenter(center: Point) {
     this.dispatchEvent(
       new CustomEvent('center-updated', {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index a14a9cc..4558dda 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -18,10 +18,10 @@
   css,
   customElement,
   html,
-  internalProperty,
   LitElement,
   property,
   PropertyValues,
+  state,
 } from 'lit-element';
 import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
 import {Rect} from './util';
@@ -41,7 +41,7 @@
   @property({type: Object})
   frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
 
-  @internalProperty() protected imageStyles: StyleInfo = {};
+  @state() protected imageStyles: StyleInfo = {};
 
   static styles = css`
     :host {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
index b42eea9..7036ce4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {ImageDiffAction} from '../../../api/diff';
+
 export interface Point {
   x: number;
   y: number;
@@ -234,3 +236,13 @@
     };
   }
 }
+
+export function createEvent(
+  detail: ImageDiffAction
+): CustomEvent<ImageDiffAction> {
+  return new CustomEvent('image-diff-action', {
+    detail,
+    bubbles: true,
+    composed: true,
+  });
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
index 3df2e18..b64f61d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -28,7 +28,12 @@
 import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
-import {getSide, isThreadEl} from '../gr-diff/gr-diff-utils';
+import {
+  getLineElByChild,
+  getSide,
+  getSideByLineEl,
+  isThreadEl,
+} from '../gr-diff/gr-diff-utils';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -110,7 +115,7 @@
     // Handle the down event on comment thread in Polymer 2
     const handled = this._handleDownOnRangeComment(target);
     if (handled) return;
-    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const lineEl = getLineElByChild(target);
     const blameSelected = this._elementDescendedFromClass(target, 'blame');
     if (!lineEl && !blameSelected) {
       return;
@@ -125,7 +130,7 @@
         target,
         'gr-comment'
       );
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const side = getSideByLineEl(lineEl);
 
       targetClasses.push(
         side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
@@ -179,9 +184,9 @@
     if (this.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
-    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const lineEl = getLineElByChild(target);
     if (!lineEl) return;
-    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     const text = this._getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
@@ -224,9 +229,9 @@
       return this._getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
-    const startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+    const startLineEl = getLineElByChild(range.startContainer);
     if (!startLineEl) return;
-    const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+    const endLineEl = getLineElByChild(range.endContainer);
     // Happens when triple click in side-by-side mode with other side empty.
     const endsAtOtherEmptySide =
       !endLineEl &&
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
index 5c9fe3f..8d7264c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
@@ -143,8 +143,8 @@
 
   test('applies selected-left on left side click', () => {
     element.classList.add('selected-right');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    MockInteractions.down(element);
+    const lineNumberEl = element.querySelector('.lineNum.left');
+    MockInteractions.down(lineNumberEl);
     assert.isTrue(
         element.classList.contains('selected-left'), 'adds selected-left');
     assert.isFalse(
@@ -154,8 +154,8 @@
 
   test('applies selected-right on right side click', () => {
     element.classList.add('selected-left');
-    element._cachedDiffBuilder.getSideByLineEl.returns('right');
-    MockInteractions.down(element);
+    const lineNumberEl = element.querySelector('.lineNum.right');
+    MockInteractions.down(lineNumberEl);
     assert.isTrue(
         element.classList.contains('selected-right'), 'adds selected-right');
     assert.isFalse(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 4a51349..638b49e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -37,7 +37,10 @@
   KeyboardShortcutMixin,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  GeneratedWebLink,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
@@ -94,11 +97,15 @@
   getPatchRangeForCommentUrl,
 } from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
-import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+import {
+  CustomKeyboardEvent,
+  EventType,
+  OpenFixPreviewEvent,
+} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
-import {toggleClass} from '../../../utils/dom-util';
+import {toggleClass, getKeyboardEvent} from '../../../utils/dom-util';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
@@ -226,6 +233,9 @@
   _isImageDiff?: boolean;
 
   @property({type: Object})
+  _editWeblinks?: GeneratedWebLink[];
+
+  @property({type: Object})
   _filesWeblinks?: FilesWebLinks;
 
   @property({type: Object})
@@ -429,10 +439,6 @@
     return this.restApiService.getPreferences();
   }
 
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
   _handleReviewedChange(e: Event) {
     this._setReviewed(
       ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
@@ -593,7 +599,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 +610,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;
 
@@ -1006,6 +1012,14 @@
       return;
     }
 
+    // Everything in the diff view is tied to the change. It seems better to
+    // force the re-creation of the diff view when the change number changes.
+    const changeChanged = this._changeNum !== value.changeNum;
+    if (this._changeNum !== undefined && changeChanged) {
+      fireEvent(this, EventType.RECREATE_DIFF_VIEW);
+      return;
+    }
+
     this._change = undefined;
     this._files = {sortedFileList: [], changeFilesByPath: {}};
     this._path = undefined;
@@ -1215,17 +1229,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 +1533,6 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getDiffDrafts() {
-    assertIsDefined(this._changeNum, '_changeNum');
-
-    return this.restApiService.getDiffDrafts(this._changeNum);
-  }
-
   _computeCommentSkips(
     commentMap?: CommentMap,
     fileList?: string[],
@@ -1806,14 +1803,19 @@
 
   _computeCanEdit(
     loggedIn?: boolean,
+    editWeblinks?: GeneratedWebLink[],
     changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
   ) {
     if (!changeChangeRecord?.base) return false;
-    return loggedIn && changeIsOpen(changeChangeRecord.base);
+    return (
+      loggedIn &&
+      changeIsOpen(changeChangeRecord.base) &&
+      (!editWeblinks || editWeblinks.length === 0)
+    );
   }
 
-  _computeIsLoggedIn(loggedIn: boolean) {
-    return loggedIn ? true : false;
+  _computeShowEditLinks(editWeblinks?: GeneratedWebLink[]) {
+    return !!editWeblinks && editWeblinks.length > 0;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 30c6f49..63bf74e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -112,10 +112,6 @@
     .prefsButton {
       text-align: right;
     }
-    .noOverflow {
-      display: block;
-      overflow: auto;
-    }
     .editMode .hideOnEdit {
       display: none;
     }
@@ -320,7 +316,10 @@
             _isBlameLoading)]]</gr-button
           >
         </span>
-        <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
+        <template
+          is="dom-if"
+          if="[[_computeCanEdit(_loggedIn, _editWeblinks, _change.*)]]"
+        >
           <span class="separator"></span>
           <span class="editButton">
             <gr-button
@@ -331,6 +330,12 @@
             >
           </span>
         </template>
+        <template is="dom-if" if="[[_computeShowEditLinks(_editWeblinks)]]">
+          <span class="separator"></span>
+          <template is="dom-repeat" items="[[_editWeblinks]]" as="weblink">
+            <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
+          </template>
+        </template>
         <span class="separator"></span>
         <div class$="diffModeSelector [[_computeModeSelectHideClass(_diff)]]">
           <span>Diff view:</span>
@@ -394,6 +399,7 @@
     hidden=""
     hidden$="[[_loading]]"
     is-image-diff="{{_isImageDiff}}"
+    edit-weblinks="{{_editWeblinks}}"
     files-weblinks="{{_filesWeblinks}}"
     diff="{{_diff}}"
     change-num="[[_changeNum]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 5c3dfdc..244bafb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -1735,19 +1735,6 @@
       });
     });
 
-    test('_paramsChanged sets in projectLookup', () => {
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-      const setStub = stubRestApi('setInProjectLookup');
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        changeNum: 101,
-        project: 'test-project',
-        path: '',
-      });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
-    });
-
     test('shift+m navigates to next unreviewed file', () => {
       element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
       element._reviewedFiles = new Set(['file1', 'file2']);
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 e254728..f48d15a 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;
@@ -318,125 +326,24 @@
       height: calc(var(--line-height-normal) + var(--spacing-s));
     }
 
-    .dividerCell {
-      vertical-align: top;
-    }
-    .dividerRow.showBoth .dividerCell {
+    .contextDivider {
       height: var(--divider-height);
+      /* Create a positioning context. */
+      transform: translateX(0px);
     }
-    .dividerRow.showAboveOnly .dividerCell,
-    .dividerRow.showBelowOnly .dividerCell {
+    .contextDivider.collapsed {
+      /* Hide divider gap, but still show child elements (expansion buttons). */
       height: 0;
     }
-
-    .verticalFlex {
-      display: flex;
-      flex-direction: column;
-      position: relative;
-    }
-    .dividerRow.showBoth .verticalFlex {
-      justify-content: center;
-      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(
-        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
-          var(--divider-height) - 1px
-      );
-    }
-    .dividerRow.showAboveOnly .verticalFlex {
-      justify-content: flex-end;
-      /* margin-top has to make room for height+1px. */
-      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-    }
-    .dividerRow.showBelowOnly .verticalFlex {
-      justify-content: flex-start;
-      /* This just pushes the container down 1 pixel as to render below the
-         1px border-top of the padding row below. The same could be achieved
-         by position:relative; top:1px.*/
-      margin-top: 1px;
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-    }
-
-    .horizontalFlex {
+    .dividerCell {
+      width: 100%;
+      height: 100%;
       display: flex;
       justify-content: center;
+      position: absolute;
+      top: 0;
+      left: 0;
     }
-    .dividerRow.showBoth .horizontalFlex {
-      align-items: center;
-    }
-    .dividerRow.showAboveOnly .horizontalFlex {
-      align-items: end;
-    }
-    .dividerRow.showBelowOnly .horizontalFlex {
-      align-items: start;
-    }
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-    }
-    .centeredButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-    }
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      justify-content: center;
-      margin-left: var(--spacing-m);
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-    }
-    .dividerRow.showBoth .aboveButton {
-      /* The size of the gap between the above and below button. */
-      margin-bottom: calc(var(--divider-height) + 1px);
-    }
-    .aboveButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 0;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: 0;
-        border-bottom-left-radius: 0;
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    .belowButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 0;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: 0;
-        border-top-right-radius: 0;
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-
     .displayLine .diff-row.target-row td {
       box-shadow: inset 0 -1px var(--border-color);
     }
@@ -631,6 +538,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/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index e96f6e4..6e20a55 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -67,19 +67,19 @@
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
   CustomKeyboardEvent,
+  DialogChangeEventDetail,
+  EventType,
   LocationChangeEvent,
   PageErrorEventDetail,
   RpcLogEvent,
   ShortcutTriggeredEvent,
   TitleChangeEventDetail,
-  DialogChangeEventDetail,
-  EventType,
 } from '../types/events';
 import {ViewState} from '../types/types';
 import {GerritView} from '../services/router/router-model';
-import {windowLocationReload} from '../utils/dom-util';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
+import {assertIsDefined} from '../utils/common-util';
 
 interface ErrorInfo {
   text: string;
@@ -96,6 +96,10 @@
   };
 }
 
+type DomIf = PolymerElement & {
+  restamp: boolean;
+};
+
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
 export class GrAppElement extends KeyboardShortcutMixin(PolymerElement) {
@@ -236,14 +240,16 @@
     this.addEventListener('location-change', e =>
       this._handleLocationChange(e)
     );
+    this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
+      this.handleRecreateView(GerritView.CHANGE)
+    );
+    this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
+      this.handleRecreateView(GerritView.DIFF)
+    );
     document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e));
     this.addEventListener('shortcut-triggered', e =>
       this._handleShortcutTriggered(e)
     );
-    // Ideally individual views should handle this event and respond with a soft
-    // reload. This is a catch-all for all views that cannot or have not
-    // implemented that.
-    this.addEventListener('reload', () => windowLocationReload());
   }
 
   /** @override */
@@ -463,35 +469,58 @@
       (this._account && this._account._account_id) || null;
   }
 
-  @observe('params.view')
-  _viewChanged(view?: GerritView) {
+  /**
+   * Throws away the view and re-creates it. The view itself fires an event, if
+   * it wants to be re-created.
+   */
+  private handleRecreateView(view: GerritView.DIFF | GerritView.CHANGE) {
+    const isDiff = view === GerritView.DIFF;
+    const domId = isDiff ? '#dom-if-diff-view' : '#dom-if-change-view';
+    const domIf = this.root!.querySelector(domId) as DomIf;
+    assertIsDefined(domIf, '<dom-if> for the view');
+    // The rendering of DomIf is debounced, so just changing _show...View and
+    // restamp properties back and forth won't work. That is why we are using
+    // timeouts.
+    // The first timeout is needed, because the _viewChanged() observer also
+    // affects _show...View and would change _show...View=false directly back to
+    // _show...View=true.
+    setTimeout(() => {
+      this._showChangeView = false;
+      this._showDiffView = false;
+      domIf.restamp = true;
+      setTimeout(() => {
+        this._showChangeView = this.params?.view === GerritView.CHANGE;
+        this._showDiffView = this.params?.view === GerritView.DIFF;
+        domIf.restamp = false;
+      }, 1);
+    }, 1);
+  }
+
+  @observe('params.*')
+  _viewChanged() {
+    const view = this.params?.view;
     this.$.errorView.classList.remove('show');
-    this.set('_showChangeListView', view === GerritView.SEARCH);
-    this.set('_showDashboardView', view === GerritView.DASHBOARD);
-    this.set('_showChangeView', view === GerritView.CHANGE);
-    this.set('_showDiffView', view === GerritView.DIFF);
-    this.set('_showSettingsView', view === GerritView.SETTINGS);
+    this._showChangeListView = view === GerritView.SEARCH;
+    this._showDashboardView = view === GerritView.DASHBOARD;
+    this._showChangeView = view === GerritView.CHANGE;
+    this._showDiffView = view === GerritView.DIFF;
+    this._showSettingsView = view === GerritView.SETTINGS;
     // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
-    this.set(
-      '_showAdminView',
+    this._showAdminView =
       view === GerritView.ADMIN ||
-        view === GerritView.GROUP ||
-        view === GerritView.REPO
-    );
-    this.set('_showCLAView', view === GerritView.AGREEMENTS);
-    this.set('_showEditorView', view === GerritView.EDIT);
+      view === GerritView.GROUP ||
+      view === GerritView.REPO;
+    this._showCLAView = view === GerritView.AGREEMENTS;
+    this._showEditorView = view === GerritView.EDIT;
     const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    this.set('_showPluginScreen', false);
+    this._showPluginScreen = false;
     // Navigation within plugin screens does not restamp gr-endpoint-decorator
     // because _showPluginScreen value does not change. To force restamp,
     // change _showPluginScreen value between true and false.
     if (isPluginScreen) {
-      setTimeout(() => this.set('_showPluginScreen', true), 1);
+      setTimeout(() => (this._showPluginScreen = true), 1);
     }
-    this.set(
-      '_showDocumentationSearch',
-      view === GerritView.DOCUMENTATION_SEARCH
-    );
+    this._showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
@@ -572,7 +601,7 @@
     if (pathname.startsWith('/c/') && Number(hash) > 0) {
       pathname += '@' + hash;
     }
-    this.set('_path', pathname);
+    this._path = pathname;
   }
 
   _updateLoginUrl() {
@@ -607,7 +636,7 @@
     const params = paramsRecord.base;
     const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
     if (params?.view && viewsToCheck.includes(params.view)) {
-      this.set('_lastSearchPage', location.pathname);
+      this._lastSearchPage = location.pathname;
     }
   }
 
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index 81a9988..a1e6ac9 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -131,7 +131,9 @@
         view-state="{{_viewState.dashboardView}}"
       ></gr-dashboard-view>
     </template>
-    <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+    <!-- Note that the change view does not have restamp="true" set, because we
+         want to re-use it as long as the change number does not change. -->
+    <template id="dom-if-change-view" is="dom-if" if="[[_showChangeView]]">
       <gr-change-view
         params="[[params]]"
         view-state="{{_viewState.changeView}}"
@@ -141,7 +143,9 @@
     <template is="dom-if" if="[[_showEditorView]]" restamp="true">
       <gr-editor-view params="[[params]]"></gr-editor-view>
     </template>
-    <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+    <!-- Note that the diff view does not have restamp="true" set, because we
+         want to re-use it as long as the change number does not change. -->
+    <template id="dom-if-diff-view" is="dom-if" if="[[_showDiffView]]">
       <gr-diff-view
         params="[[params]]"
         change-view-state="{{_viewState.changeView}}"
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 2ae85f7..4d60274 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -106,6 +106,16 @@
   leftSide?: boolean;
   commentLink?: boolean;
 }
+
+export interface AppElementDiffEditViewParam {
+  view: GerritView.EDIT;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path: string;
+  patchNum: RevisionPatchSetNum;
+  lineNum?: number;
+}
+
 export interface AppElementChangeViewParams {
   view: GerritView.CHANGE;
   changeNum: NumericChangeId;
@@ -138,6 +148,7 @@
   | AppElementSettingsParam
   | AppElementAgreementParam
   | AppElementDiffViewParam
+  | AppElementDiffEditViewParam
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/plugins/gr-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-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 64bae58..ab4c5a5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -85,7 +85,7 @@
     type: Boolean,
     reflectToAttribute: true,
     computed:
-      '_computeCancelLeftPadding(hideAvatar, _config, ' +
+      '_computeCancelLeftPadding(hideAvatar, ' +
       'highlightAttention, account, change, forceAttention)',
   })
   cancelLeftPadding = false;
@@ -127,56 +127,41 @@
   }
 
   _isAttentionSetEnabled(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo
   ) {
-    return (
-      !!config &&
-      !!config.change &&
-      !!config.change.enable_attention_set &&
-      !!highlight &&
-      !!change &&
-      !!account &&
-      !isServiceUser(account)
-    );
+    return highlight && !!change && !!account && !isServiceUser(account);
   }
 
   _computeCancelLeftPadding(
     hideAvatar: boolean,
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
     force: boolean
   ) {
     return (
-      !hideAvatar &&
-      !this._hasAttention(config, highlight, account, change, force)
+      !hideAvatar && !this._hasAttention(highlight, account, change, force)
     );
   }
 
   _hasAttention(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
     force: boolean
   ) {
-    return (
-      force || this._hasUnforcedAttention(config, highlight, account, change)
-    );
+    return force || this._hasUnforcedAttention(highlight, account, change);
   }
 
   _hasUnforcedAttention(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo
   ) {
     return (
-      this._isAttentionSetEnabled(config, highlight, account, change) &&
+      this._isAttentionSetEnabled(highlight, account, change) &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
@@ -184,13 +169,12 @@
   }
 
   _computeHasAttentionClass(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
     force: boolean
   ) {
-    return this._hasAttention(config, highlight, account, change, force)
+    return this._hasAttention(highlight, account, change, force)
       ? 'hasAttention'
       : '';
   }
@@ -266,7 +250,6 @@
   }
 
   _computeAttentionButtonEnabled(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
@@ -275,13 +258,12 @@
   ) {
     if (selected) return true;
     return (
-      this._hasUnforcedAttention(config, highlight, account, change) &&
+      this._hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
 
   _computeAttentionIconTitle(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo,
@@ -290,7 +272,6 @@
     selected: boolean
   ) {
     const enabled = this._computeAttentionButtonEnabled(
-      config,
       highlight,
       account,
       change,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index ff40aeb..c5b66ce3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -109,23 +109,23 @@
     </template>
     <template
       is="dom-if"
-      if="[[_hasAttention(_config, highlightAttention, account, change, forceAttention)]]"
+      if="[[_hasAttention(highlightAttention, account, change, forceAttention)]]"
     >
       <gr-button
         id="attentionButton"
         link=""
         aria-label="Remove user from attention set"
         on-click="_handleRemoveAttentionClick"
-        disabled="[[!_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, selected)]]"
-        has-tooltip="[[_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, false)]]"
-        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
+        disabled="[[!_computeAttentionButtonEnabled(highlightAttention, account, change, _selfAccount, selected)]]"
+        has-tooltip="[[_computeAttentionButtonEnabled(highlightAttention, account, change, _selfAccount, false)]]"
+        title="[[_computeAttentionIconTitle(highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
         ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
       </gr-button>
     </template>
   </span>
   <span
     id="hovercardTarget"
-    class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
+    class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
   >
     <template is="dom-if" if="[[!hideAvatar]]">
       <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
index f37aa01..459c8c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -23,12 +23,14 @@
 
 suite('gr-account-label tests', () => {
   let element;
+  const kermit = createAccount('kermit', 31);
 
   function createAccount(name, id) {
     return {name, _account_id: id};
   }
 
   setup(() => {
+    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
     element._config = {
@@ -81,10 +83,8 @@
 
   suite('attention set', () => {
     setup(async () => {
-      const kermit = createAccount('kermit', 31);
       element.highlightAttention = true;
       element._config = {
-        change: {enable_attention_set: true},
         user: {anonymous_coward_name: 'Anonymous Coward'},
       };
       element._selfAccount = kermit;
@@ -98,15 +98,18 @@
     });
 
     test('show attention button', () => {
-      assert.ok(element.shadowRoot.querySelector('#attentionButton'));
+      const button = element.shadowRoot.querySelector('#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
     });
 
-    test('tap attention button', () => {
+    test('tap attention button', async () => {
       const apiStub = stubRestApi(
           'removeFromAttentionSet')
           .callsFake(() => Promise.resolve());
       const button = element.shadowRoot.querySelector('#attentionButton');
       assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
       MockInteractions.tap(button);
       assert.isTrue(apiStub.calledOnce);
       assert.equal(apiStub.lastCall.args[1], 42);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 1e54f69..231fc36 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -26,6 +26,7 @@
   Suggestion,
   AccountInfo,
   GroupInfo,
+  EmailAddress,
 } from '../../../types/common';
 import {
   GrReviewerSuggestionsProvider,
@@ -37,6 +38,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
 import {fireAlert} from '../../../utils/event-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -184,6 +186,8 @@
 
   reporting: ReportingService;
 
+  private pendingRemoval: Set<AccountInput> = new Set();
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
@@ -233,6 +237,7 @@
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
       const account = {...item.account, _pendingAdd: true};
+      this.removeFromPendingRemoval(account);
       this.push('accounts', account);
       itemTypeAdded = 'account';
     } else if (isGroupObjectInput(item)) {
@@ -242,6 +247,7 @@
       }
       const group = {...item.group, _pendingAdd: true, _group: true};
       this.push('accounts', group);
+      this.removeFromPendingRemoval(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
@@ -251,8 +257,9 @@
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        const account = {email: item, _pendingAdd: true};
+        const account = {email: item as EmailAddress, _pendingAdd: true};
         this.push('accounts', account);
+        this.removeFromPendingRemoval(account);
         itemTypeAdded = 'email';
       }
     }
@@ -283,32 +290,16 @@
     return classes.join(' ');
   }
 
-  _accountMatches(a: AccountInput, b: AccountInput) {
-    // TODO(TS): seems a & b always exists ?
-    if (a && b) {
-      // both conditions are checking against AccountInfo
-      // and only check a not b.. typeguard won't work very good without
-      // changing logic, so keep it as inline casting
-      if ((a as AccountInfoInput)._account_id) {
-        return (
-          (a as AccountInfoInput)._account_id ===
-          (b as AccountInfoInput)._account_id
-        );
-      }
-      if ((a as AccountInfoInput).email) {
-        return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
-      }
-    }
-    return a === b;
-  }
-
   _computeRemovable(account: AccountInput, readonly: boolean) {
     if (readonly) {
       return false;
     }
     if (this.removableValues) {
       for (let i = 0; i < this.removableValues.length; i++) {
-        if (this._accountMatches(this.removableValues[i], account)) {
+        if (
+          accountOrGroupKey(this.removableValues[i]) ===
+          accountOrGroupKey(account)
+        ) {
           return true;
         }
       }
@@ -328,16 +319,9 @@
       return;
     }
     for (let i = 0; i < this.accounts.length; i++) {
-      let matches;
-      const account = this.accounts[i];
-      if (toRemove._group) {
-        matches =
-          (toRemove as GroupInfoInput).id === (account as GroupInfoInput).id;
-      } else {
-        matches = this._accountMatches(toRemove, account);
-      }
-      if (matches) {
+      if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
         this.splice('accounts', i, 1);
+        this.pendingRemoval.add(toRemove);
         this.reporting.reportInteraction(`Remove from ${this.id}`);
         return;
       }
@@ -445,6 +429,26 @@
       });
   }
 
+  removals(): AccountAddition[] {
+    return Array.from(this.pendingRemoval).map(account => {
+      if (isGroupInfoInput(account)) {
+        return {group: account};
+      } else if (isAccountInfoInput(account)) {
+        return {account};
+      } else {
+        throw new Error('AccountInput must be either Account or Group.');
+      }
+    });
+  }
+
+  removeFromPendingRemoval(account: AccountInput) {
+    this.pendingRemoval.delete(account);
+  }
+
+  clearPendingRemovals() {
+    this.pendingRemoval.clear();
+  }
+
   _computeEntryHidden(
     maxCount: number,
     accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
index 20e8672..693f4cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -441,19 +441,6 @@
     });
   });
 
-  test('_accountMatches', () => {
-    const acct = makeAccount();
-
-    assert.isTrue(element._accountMatches(acct, acct));
-    acct.email = 'test';
-    assert.isTrue(element._accountMatches(acct, acct));
-    assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-    assert.isFalse(element._accountMatches({}, acct));
-    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-  });
-
   suite('keyboard interactions', () => {
     test('backspace at text input start removes last account', async () => {
       const input = element.$.entry.$.input;
diff --git a/polygerrit-ui/app/elements/shared/gr-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-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index abd4469..d0a440a 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -38,7 +38,6 @@
   getLastUpdate,
   getReason,
   hasAttention,
-  isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {ReviewerState} from '../../../constants/constants';
 import {CURRENT} from '../../../utils/patch-set-util';
@@ -109,7 +108,6 @@
 
   get isAttentionEnabled() {
     return (
-      isAttentionSetEnabled(this._config) &&
       !!this.highlightAttention &&
       !!this.change &&
       canHaveAttention(this.account)
@@ -117,7 +115,7 @@
   }
 
   get hasUserAttention() {
-    return hasAttention(this._config, this.account, this.change);
+    return hasAttention(this.account, this.change);
   }
 
   _computeReason(change?: ChangeInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index 1c67e13..07969bd 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -37,9 +37,6 @@
 
   setup(async () => {
     stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
-    stubRestApi('getConfig').returns(
-        Promise.resolve({change: {enable_attention_set: true}})
-    );
     element = basicFixture.instantiate();
     element.account = {...ACCOUNT};
     element.change = {
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 2259ca1..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..852f0b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -1036,63 +1036,58 @@
     offset?: 'n,z' | number,
     options?: string
   ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
-    return this.getConfig(false)
-      .then(config => {
-        // TODO(TS): config can be null/undefined. Need some checks
-        options = options || this._getChangesOptionsHex(config);
-        // Issue 4524: respect legacy token with max sortkey.
-        if (offset === 'n,z') {
-          offset = 0;
+    options = options || this._getChangesOptionsHex();
+    // Issue 4524: respect legacy token with max sortkey.
+    if (offset === 'n,z') {
+      offset = 0;
+    }
+    const params: QueryChangesParams = {
+      O: options,
+      S: offset || 0,
+    };
+    if (changesPerPage) {
+      params.n = changesPerPage;
+    }
+    if (query && query.length > 0) {
+      params.q = query;
+    }
+    const request = {
+      url: '/changes/',
+      params,
+      reportUrlAsIs: true,
+    };
+
+    return Promise.resolve(
+      this._restApiHelper.fetchJSON(request, true) as Promise<
+        ChangeInfo[] | ChangeInfo[][] | undefined
+      >
+    ).then(response => {
+      if (!response) {
+        return;
+      }
+      const iterateOverChanges = (arr: ChangeInfo[]) => {
+        for (const change of arr) {
+          this._maybeInsertInLookup(change);
         }
-        const params: QueryChangesParams = {
-          O: options,
-          S: offset || 0,
-        };
-        if (changesPerPage) {
-          params.n = changesPerPage;
+      };
+      // Response may be an array of changes OR an array of arrays of
+      // changes.
+      if (query instanceof Array) {
+        // Normalize the response to look like a multi-query response
+        // when there is only one query.
+        const responseArray: Array<ChangeInfo[]> =
+          query.length === 1
+            ? [response as ChangeInfo[]]
+            : (response as ChangeInfo[][]);
+        for (const arr of responseArray) {
+          iterateOverChanges(arr);
         }
-        if (query && query.length > 0) {
-          params.q = query;
-        }
-        return {
-          url: '/changes/',
-          params,
-          reportUrlAsIs: true,
-        };
-      })
-      .then(
-        req =>
-          this._restApiHelper.fetchJSON(req, true) as Promise<
-            ChangeInfo[] | ChangeInfo[][] | undefined
-          >
-      )
-      .then(response => {
-        if (!response) {
-          return;
-        }
-        const iterateOverChanges = (arr: ChangeInfo[]) => {
-          for (const change of arr) {
-            this._maybeInsertInLookup(change);
-          }
-        };
-        // Response may be an array of changes OR an array of arrays of
-        // changes.
-        if (query instanceof Array) {
-          // Normalize the response to look like a multi-query response
-          // when there is only one query.
-          const responseArray: Array<ChangeInfo[]> =
-            query.length === 1
-              ? [response as ChangeInfo[]]
-              : (response as ChangeInfo[][]);
-          for (const arr of responseArray) {
-            iterateOverChanges(arr);
-          }
-          return responseArray;
-        } else {
-          iterateOverChanges(response as ChangeInfo[]);
-          return response as ChangeInfo[];
-        }
-      });
+        return responseArray;
+      } else {
+        iterateOverChanges(response as ChangeInfo[]);
+        return response as ChangeInfo[];
+      }
+    });
   }
 
   /**
@@ -1134,7 +1129,7 @@
     });
   }
 
-  _getChangesOptionsHex(config?: ServerInfo) {
+  _getChangesOptionsHex() {
     if (
       window.DEFAULT_DETAIL_HEXES &&
       window.DEFAULT_DETAIL_HEXES.dashboardPage
@@ -1145,9 +1140,6 @@
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
     ];
-    if (!config?.change?.enable_attention_set) {
-      options.push(ListChangesOption.REVIEWED);
-    }
 
     return listChangesOptionsToHex(...options);
   }
@@ -2123,22 +2115,6 @@
     });
   }
 
-  saveChangeReviewed(
-    changeNum: NumericChangeId,
-    reviewed: boolean
-  ): Promise<Response | undefined> {
-    return this.getConfig().then(config => {
-      const isAttentionSetEnabled =
-        !!config && !!config.change && config.change.enable_attention_set;
-      if (isAttentionSetEnabled) return;
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: HttpMethod.PUT,
-        endpoint: reviewed ? '/reviewed' : '/unreviewed',
-      });
-    });
-  }
-
   send(
     method: HttpMethod,
     url: string,
@@ -2316,12 +2292,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/gr-change-table-mixin/gr-change-table-mixin.ts b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
index 28b7a50..e3b75de 100644
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
@@ -70,7 +70,6 @@
         if (column === 'Assignee') return !!config.change.enable_assignee;
         if (column === 'Comments')
           return experiments.includes('comments-column');
-        if (column === 'Reviewers') return !!config.change.enable_attention_set;
         return true;
       }
 
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 58e50d3..62589f8 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -102,11 +102,13 @@
 import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
+import {check, 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 {
@@ -220,11 +222,6 @@
   viewMap?: Map<ShortcutSection, SectionView>
 ) => void;
 
-interface ShortcutEnabledElement extends PolymerElement {
-  // TODO: should replace with Map so we can have proper type here
-  keyboardShortcuts(): {[shortcut: string]: string};
-}
-
 interface ShortcutHelpItem {
   shortcut: Shortcut;
   text: string;
@@ -530,19 +527,6 @@
   'Emoji dropdown'
 );
 
-// Must be declared outside behavior implementation to be accessed inside
-// behavior functions.
-
-function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
-  const event = dom(e.detail ? e.detail.keyboardEvent : e);
-  // TODO(TS): worth checking if this still holds or not, if no, remove this.
-  // When e is a keyboardEvent, e.event is not null.
-  if ('event' in event && (event as CustomKeyboardEvent).event) {
-    return (event as CustomKeyboardEvent).event;
-  }
-  return event as CustomKeyboardEvent;
-}
-
 /**
  * Shortcut manager, holds all hosts, bindings and listeners.
  */
@@ -569,14 +553,9 @@
     return this.bindings.get(shortcut);
   }
 
-  attachHost(host: PolymerElement | ShortcutEnabledElement) {
-    if (!('keyboardShortcuts' in host)) {
-      return;
-    }
-    const shortcuts = host.keyboardShortcuts();
-    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+  attachHost(host: PolymerElement, shortcuts: Map<string, string>) {
+    this.activeHosts.set(host, shortcuts);
     this.notifyListeners();
-    return shortcuts;
   }
 
   detachHost(host: PolymerElement) {
@@ -761,16 +740,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 +774,16 @@
 
       ShortcutSection = ShortcutSection;
 
+      private _disableKeyboardShortcuts = false;
+
+      private readonly restApiService = appContext.restApiService;
+
+      /** Used to disable shortcuts when the element is not visible. */
+      private observer?: IntersectionObserver;
+
+      /** Are shortcuts currently enabled? True only when element is visible. */
+      private bindingsEnabled = false;
+
       modifierPressed(event: CustomKeyboardEvent) {
         /* We are checking for g/v as modifiers pressed. There are cases such as
          * pressing v and then /, where we want the handler for / to be triggered.
@@ -812,20 +791,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,14 +898,62 @@
       /** @override */
       connectedCallback() {
         super.connectedCallback();
-        const shortcuts = shortcutManager.attachHost(this);
-        if (!shortcuts) {
-          return;
-        }
+        this.restApiService.getPreferences().then(prefs => {
+          if (prefs?.disable_keyboard_shortcuts) {
+            this._disableKeyboardShortcuts = true;
+          }
+        });
+        this.createVisibilityObserver();
+        this.enableBindings();
+      }
 
-        for (const key of Object.keys(shortcuts)) {
-          // TODO(TS): not needed if convert shortcuts to Map
-          this._addOwnKeyBindings(key as Shortcut, shortcuts[key]);
+      /** @override */
+      disconnectedCallback() {
+        this.destroyVisibilityObserver();
+        this.disableBindings();
+        super.disconnectedCallback();
+      }
+
+      /**
+       * Creates an intersection observer that enables bindings when the
+       * element is visible and disables them when the element is hidden.
+       */
+      private createVisibilityObserver() {
+        if (!this.hasKeyboardShortcuts()) return;
+        if (this.observer) return;
+        this.observer = new IntersectionObserver(entries => {
+          check(entries.length === 1, 'Expected one observer entry.');
+          const isVisible = entries[0].isIntersecting;
+          if (isVisible) {
+            this.enableBindings();
+          } else {
+            this.disableBindings();
+          }
+        });
+        this.observer.observe(this);
+      }
+
+      private destroyVisibilityObserver() {
+        if (this.observer) this.observer.unobserve(this);
+      }
+
+      /**
+       * Enables all the shortcuts returned by keyboardShortcuts().
+       * This is a private method being called when the element becomes
+       * connected or visible.
+       */
+      private enableBindings() {
+        if (!this.hasKeyboardShortcuts()) return;
+        if (this.bindingsEnabled) return;
+        this.bindingsEnabled = true;
+
+        const shortcuts = new Map<string, string>(
+          Object.entries(this.keyboardShortcuts())
+        );
+        shortcutManager.attachHost(this, shortcuts);
+
+        for (const [key, value] of shortcuts.entries()) {
+          this._addOwnKeyBindings(key as Shortcut, value);
         }
 
         // each component that uses this behaviour must be aware if go key is
@@ -959,12 +978,21 @@
         }
       }
 
-      /** @override */
-      disconnectedCallback() {
+      /**
+       * Disables all the shortcuts returned by keyboardShortcuts().
+       * This is a private method being called when the element becomes
+       * disconnected or invisible.
+       */
+      private disableBindings() {
+        if (!this.bindingsEnabled) return;
+        this.bindingsEnabled = false;
         if (shortcutManager.detachHost(this)) {
           this.removeOwnKeyBindings();
         }
-        super.disconnectedCallback();
+      }
+
+      private hasKeyboardShortcuts() {
+        return Object.entries(this.keyboardShortcuts()).length > 0;
       }
 
       keyboardShortcuts() {
@@ -1087,8 +1115,6 @@
   bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
   modifierPressed(event: CustomKeyboardEvent): boolean;
-  isModifierPressed(event: CustomKeyboardEvent, modifier: Modifier): boolean;
-  getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
   addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   // TODO(TS): Remove underscore. Apparently not a private method.
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
index 180dbe7..d5e7fe7 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -181,13 +181,7 @@
             mapToObject(mgr.activeShortcutsBySection()),
             {});
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.NEXT_FILE]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, null]]));
         assert.deepEqual(
             mapToObject(mgr.activeShortcutsBySection()),
             {
@@ -196,13 +190,7 @@
               ],
             });
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.NEXT_LINE]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, null]]));
         assert.deepEqual(
             mapToObject(mgr.activeShortcutsBySection()),
             {
@@ -214,14 +202,10 @@
               ],
             });
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.SEARCH]: null,
-              [Shortcut.GO_TO_OPENED_CHANGES]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([
+          [Shortcut.SEARCH, null],
+          [Shortcut.GO_TO_OPENED_CHANGES, null],
+        ]));
         assert.deepEqual(
             mapToObject(mgr.activeShortcutsBySection()),
             {
@@ -254,17 +238,13 @@
 
         assert.deepEqual(mapToObject(mgr.directoryView()), {});
 
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.GO_TO_OPENED_CHANGES]: null,
-              [Shortcut.NEXT_FILE]: null,
-              [Shortcut.NEXT_LINE]: null,
-              [Shortcut.SAVE_COMMENT]: null,
-              [Shortcut.SEARCH]: null,
-            };
-          },
-        });
+        mgr.attachHost({}, new Map([
+          [Shortcut.GO_TO_OPENED_CHANGES, null],
+          [Shortcut.NEXT_FILE, null],
+          [Shortcut.NEXT_LINE, null],
+          [Shortcut.SAVE_COMMENT, null],
+          [Shortcut.SEARCH, null],
+        ]));
         assert.deepEqual(
             mapToObject(mgr.directoryView()),
             {
@@ -377,27 +357,6 @@
     assert.isTrue(spy.lastCall.returnValue);
   });
 
-  test('isModifierPressed returns accurate value', () => {
-    const spy = sinon.spy(element, 'isModifierPressed');
-    element._handleKey = e => {
-      element.isModifierPressed(e, 'shiftKey');
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-  });
-
   suite('GO_KEY timing', () => {
     let handlerStub;
 
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 8dcb80e..ac49388 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -240,6 +240,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-fab",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/paper-icon-button",
     license: SharedLicenses.Polymer2015
   },
@@ -308,6 +312,14 @@
     }
   },
   {
+    name: "codemirror-minified",
+    license: {
+      name: "codemirror-minified",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  },
+  {
     name: "isarray",
     license: SharedLicenses.IsArray
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index c943bff..6d5e475 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -22,6 +22,7 @@
     "@polymer/paper-dialog-behavior": "^3.0.1",
     "@polymer/paper-dialog-scrollable": "^3.0.1",
     "@polymer/paper-dropdown-menu": "^3.2.0",
+    "@polymer/paper-fab": "^3.0.1",
     "@polymer/paper-input": "^3.2.1",
     "@polymer/paper-item": "^3.0.1",
     "@polymer/paper-listbox": "^3.0.1",
@@ -34,7 +35,8 @@
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "lit-element": "^2.4.0",
+    "codemirror-minified": "^5.60.0",
+    "lit-element": "^2.5.1",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index dab894e..9af86d2 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -73,7 +73,7 @@
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
     changeService: () => new ChangeService(),
-    checksService: () => new ChecksService(),
+    checksService: () => new ChecksService(appContext.reportingService),
     jsApiService: () => new GrJsApiInterface(),
     storageService: () => new GrStorageService(),
     configService: () => new ConfigService(),
diff --git a/polygerrit-ui/app/services/change/change-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..40a1596 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;
     });
@@ -74,6 +75,9 @@
     document.addEventListener('visibilitychange', () => {
       this.documentVisibilityChange$.next(undefined);
     });
+    document.addEventListener('reload', () => {
+      this.reloadAll();
+    });
   }
 
   setPatchset(num: PatchSetNumber) {
@@ -150,8 +154,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 +186,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/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 15841f3..0a0881b 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -79,10 +79,11 @@
   if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
   if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
   if (hasResultsOf(run, Category.INFO)) return Category.INFO;
+  if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
   return undefined;
 }
 
-export function iconForCategory(category: Category | 'SUCCESS') {
+export function iconForCategory(category: Category) {
   switch (category) {
     case Category.ERROR:
       return 'error';
@@ -90,7 +91,7 @@
       return 'info-outline';
     case Category.WARNING:
       return 'warning';
-    case 'SUCCESS':
+    case Category.SUCCESS:
       return 'check-circle-outline';
     default:
       assertNever(category, `Unsupported category: ${category}`);
@@ -210,12 +211,14 @@
 export function level(cat?: Category) {
   if (!cat) return -1;
   switch (cat) {
-    case Category.INFO:
+    case Category.SUCCESS:
       return 0;
-    case Category.WARNING:
+    case Category.INFO:
       return 1;
-    case Category.ERROR:
+    case Category.WARNING:
       return 2;
+    case Category.ERROR:
+      return 3;
   }
 }
 
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..3a890d9 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>;
@@ -710,11 +710,6 @@
 
   addAccountEmail(email: string): Promise<Response>;
 
-  saveChangeReviewed(
-    changeNum: NumericChangeId,
-    reviewed: boolean
-  ): Promise<Response | undefined>;
-
   saveChangeStarred(
     changeNum: NumericChangeId,
     starred: boolean
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 838d82b..d768c96 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -253,6 +253,7 @@
     --status-wip: #795548;
     --status-private: var(--purple-500);
     --status-conflict: var(--red-600);
+    --status-revert-created: #e64a19;
     --status-active: var(--blue-700);
     --status-ready: var(--pink-800);
     --status-custom: var(--purple-900);
@@ -336,6 +337,7 @@
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
     --ranged-comment-hint-text-color: var(--orange-900);
+    --token-highlighting-color: #fffd54;
 
     /* syntax colors */
     --syntax-attr-color: #219;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index d6e96dd..dec438d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -151,6 +151,7 @@
       --status-wip: #bcaaa4;
       --status-private: var(--purple-200);
       --status-conflict: var(--red-300);
+      --status-revert-created: #ff8a65;
       --status-active: var(--blue-400);
       --status-ready: var(--pink-500);
       --status-custom: var(--purple-400);
@@ -174,7 +175,7 @@
       --header-text-color: var(--primary-text-color);
 
       /* diff colors */
-      --dark-add-highlight-color: #133820;
+      --dark-add-highlight-color: var(--green-tonal); 
       --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
       --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
       --dark-remove-highlight-color: #62110f;
@@ -187,7 +188,7 @@
       --diff-selection-background-color: #3a71d8;
       --diff-tab-indicator-color: var(--deemphasized-text-color);
       --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #0f401f;
+      --light-add-highlight-color: #182b1f;
       --light-rebased-add-highlight-color: #487165;
       --diff-moved-in-background: #1d4042;
       --diff-moved-out-background: #230e34;
@@ -198,6 +199,7 @@
       --coverage-covered: #112826;
       --coverage-not-covered: #6b3600;
       --ranged-comment-hint-text-color: var(--blue-50);
+      --token-highlighting-color: var(--yellow-tonal);
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 395c9f67..3971fa5 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -444,9 +444,6 @@
   saveChangeReview() {
     return Promise.resolve(new Response());
   },
-  saveChangeReviewed(): Promise<Response | undefined> {
-    return Promise.resolve(new Response());
-  },
   saveChangeStarred(): Promise<Response> {
     return Promise.resolve(new Response());
   },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 778e58d..fa40529 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -348,7 +348,6 @@
     update_delay: 0,
     mergeability_computation_behavior:
       MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
-    enable_attention_set: false,
     enable_assignee: false,
   };
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 26b99ac..34f7fe4 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -71,6 +71,11 @@
   return found;
 }
 
+export function isVisible(el: Element) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
+}
+
 // Some tests/elements can define its own binding. We want to restore bindings
 // at the end of the test. The TestKeyboardShortcutBinder store bindings in
 // stack, so it is possible to override bindings in nested suites.
@@ -170,6 +175,10 @@
   return sinon.stub(appContext.storageService, method);
 }
 
+export function spyStorage<K extends keyof StorageService>(method: K) {
+  return sinon.spy(appContext.storageService, method);
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 3183510..30eb658 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -490,6 +490,7 @@
   hashtags?: ActionInfo;
   assignee?: ActionInfo;
   ready?: ActionInfo;
+  includedIn?: ActionInfo;
 }
 
 /**
@@ -528,6 +529,7 @@
   real_author?: AccountInfo;
   date: Timestamp;
   message: string;
+  accounts_in_message?: AccountInfo[];
   tag?: ReviewInputTag;
   _revision_number?: PatchSetNum;
 }
@@ -636,6 +638,7 @@
   subject: string;
   message: string;
   web_links?: WebLinkInfo[];
+  resolve_conflicts_web_links?: WebLinkInfo[];
 }
 
 export interface CommitInfoWithRequiredCommit extends CommitInfo {
@@ -808,7 +811,6 @@
   submit_whole_topic?: boolean;
   disable_private_changes?: boolean;
   mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_attention_set: boolean;
   enable_assignee: boolean;
 }
 
@@ -1766,6 +1768,7 @@
   email_strategy: EmailStrategy;
   default_base_for_merges: DefaultBase;
   publish_comments_on_push?: boolean;
+  disable_keyboard_shortcuts?: boolean;
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
@@ -1829,7 +1832,8 @@
 /**
  * The AddReviewerResult entity describes the result of adding a reviewer to a
  * change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#add-reviewer-result
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-result
+ * TODO(paiking): update this to ReviewerResult while considering removals.
  */
 export interface AddReviewerResult {
   input: AccountId | GroupId | EmailAddress;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index c3b38c6..35c5726 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -51,6 +51,12 @@
    * entries.
    */
   web_links?: DiffWebLinkInfo[];
+
+  /**
+   * Links to edit the file in external sites as a list of WebLinkInfo
+   * entries.
+   */
+  edit_web_links?: WebLinkInfo[];
 }
 
 /**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index ff08c57..3a61437 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -34,6 +34,8 @@
   OPEN_FIX_PREVIEW = 'open-fix-preview',
   CLOSE_FIX_PREVIEW = 'close-fix-preview',
   PAGE_ERROR = 'page-error',
+  RECREATE_CHANGE_VIEW = 'recreate-change-view',
+  RECREATE_DIFF_VIEW = 'recreate-diff-view',
   RELOAD = 'reload',
   REPLY = 'reply',
   SERVER_ERROR = 'server-error',
@@ -56,6 +58,7 @@
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
+    'create-fix-comment': CreateFixCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
     /* prettier-ignore */
@@ -75,6 +78,8 @@
     'gr-rpc-log': RpcLogEvent;
     'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
+    /* prettier-ignore */
+    'reload': ReloadEvent;
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
@@ -135,6 +140,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/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index bb9f328..ff37608 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -15,8 +15,16 @@
  * limitations under the License.
  */
 
-import {AccountId, AccountInfo, EmailAddress} from '../types/common';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  GroupInfo,
+  isAccount,
+  isGroup,
+} from '../types/common';
 import {AccountTag} from '../constants/constants';
+import {assertNever} from './common-util';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
   if (account._account_id) return account._account_id;
@@ -40,6 +48,12 @@
   return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
 }
 
+export function accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+  if (isAccount(entry)) return accountKey(entry);
+  if (isGroup(entry)) return entry.id;
+  assertNever(entry, 'entry must be account or group');
+}
+
 export function uniqueDefinedAvatar(
   account: AccountInfo,
   index: number,
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 5b2762c..d39553a 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -19,33 +19,15 @@
 import {isServiceUser} from './account-util';
 import {hasOwnProperty} from './common-util';
 
-// You would typically use a ServerInfo here, but this utility does not care
-// about all the other parameters in that object.
-interface SimpleServerInfo {
-  change?: {
-    enable_attention_set?: boolean;
-  };
-}
-
-const CONFIG_ENABLED: SimpleServerInfo = {
-  change: {enable_attention_set: true},
-};
-
-export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
-  return !!config?.change?.enable_attention_set;
-}
-
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
 }
 
 export function hasAttention(
-  config?: SimpleServerInfo,
   account?: AccountInfo,
   change?: ChangeInfo
 ): boolean {
   return (
-    isAttentionSetEnabled(config) &&
     canHaveAttention(account) &&
     !!change?.attention_set &&
     hasOwnProperty(change?.attention_set, account!._account_id!)
@@ -53,13 +35,13 @@
 }
 
 export function getReason(account?: AccountInfo, change?: ChangeInfo) {
-  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  if (!hasAttention(account, change)) return '';
   const entry = change!.attention_set![account!._account_id!];
   return entry?.reason ? entry.reason : '';
 }
 
 export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
-  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  if (!hasAttention(account, change)) return '';
   const entry = change!.attention_set![account!._account_id!];
   return entry?.last_update ? entry.last_update : '';
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
index 71735d5..9d8c086 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.js
+++ b/polygerrit-ui/app/utils/attention-set-util_test.js
@@ -29,9 +29,6 @@
 
 suite('attention-set-util', () => {
   test('hasAttention', () => {
-    const config = {
-      change: {enable_attention_set: true},
-    };
     const change = {
       attention_set: {
         31415926535: {
@@ -40,7 +37,7 @@
       },
     };
 
-    assert.isTrue(hasAttention(config, KERMIT, change));
+    assert.isTrue(hasAttention(KERMIT, change));
   });
 
   test('getReason', () => {
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index eda988a..9692ab31 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -24,6 +24,7 @@
   SUBMITTED = 'Submitted',
   PARENT = 'Parent',
   MERGED_AS = 'Merged as',
+  REVERT_CREATED_AS = 'Revert Created as',
   STRATEGY = 'Strategy',
   UPDATED = 'Updated',
   CC = 'CC',
@@ -56,6 +57,7 @@
   ALWAYS_HIDE: [
     Metadata.PARENT,
     Metadata.MERGED_AS,
+    Metadata.REVERT_CREATED_AS,
     Metadata.STRATEGY,
     Metadata.UPDATED,
   ],
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index d7fb5d0..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/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index da075f1..4ba27bc 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -133,6 +133,10 @@
   fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
 }
 
+export function fireReload(target: EventTarget, clearPatchset?: boolean) {
+  fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
+}
+
 export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
   el: EventTarget,
   eventName: K
diff --git a/polygerrit-ui/app/utils/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..543017a 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -270,6 +270,17 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.3.1"
 
+"@polymer/paper-fab@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-fab/-/paper-fab-3.0.1.tgz#2636359e7fb70dd5a549ed92ba9b3bdb9ff86bf8"
+  integrity sha512-LO8ckgd72MnAtC1WiPd5CFR27WC/dEuY/lOIQuHYdEjwI62+iiV7Bmr7uoQ9wvvV71qMFdMIOyq/03KklsuAzw==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/paper-icon-button@^3.0.0-pre.26":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/paper-icon-button/-/paper-icon-button-3.0.2.tgz#a1254faadc2c8dd135ce1ae33bcc161a94c31f65"
@@ -407,22 +418,27 @@
 "ba-linkify@file:../../lib/ba-linkify/src":
   version "1.0.0"
 
+codemirror-minified@^5.60.0:
+  version "5.60.0"
+  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.60.0.tgz#d0d8b62ab6d50864903f812cd203b97193b1fb75"
+  integrity sha512-Ru9aChh07DwYrUEfI+LznD3l8GxOPlYKAqcG8qxIlxRZTyw4BPfZsV5m1oUz1y6knxaPxa23FwM/R5rWggRnKg==
+
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-lit-element@^2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.4.0.tgz#b22607a037a8fc08f5a80736dddf7f3f5d401452"
-  integrity sha512-pBGLglxyhq/Prk2H91nA0KByq/hx/wssJBQFiYqXhGDvEnY31PRGYf1RglVzyLeRysu0IHm2K0P196uLLWmwFg==
+lit-element@^2.5.1:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.5.1.tgz#3fa74b121a6cd22902409ae3859b7847d01aa6b6"
+  integrity sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==
   dependencies:
     lit-html "^1.1.1"
 
 lit-html@^1.1.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.3.0.tgz#c80f3cc5793a6dea6c07172be90a70ab20e56034"
-  integrity sha512-0Q1bwmaFH9O14vycPHw8C/IeHMk/uSDldVLIefu/kfbTBGIc44KGH6A8p1bDfxUfHdc8q6Ct7kQklWoHgr4t1Q==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0"
+  integrity sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==
 
 page@^1.11.6:
   version "1.11.6"
diff --git a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md b/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
deleted file mode 100644
index 717f683..0000000
--- a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# In-browser Editing in Gerrit
-
-### What's going on?
-
-Until Q1 of 2018, editing a file in the browser was not supported by Gerrit's
-new UI. This feature is now done and ready for use.
-
-Read on for a walkthrough of the feature!
-
-### Creating an edit
-
-Click on the "Edit" button to begin.
-
-One may also go to the project mmanagement page (Browse => Repository =>
-Commands => Create Change) to create a new change.
-
-![](./img/into_edit.png)
-
-### Performing an action
-
-The buttons in the file list header open dialogs to perform actions on any file
-in the repo.
-
-*   Open - opens an existing or new file from the repo in an editor.
-*   Delete - deletes an existing file from the repo.
-*   Rename - renames an existing file in the repo.
-
-To leave edit mode and restore the normal buttons to the file list, click "Stop
-editing".
-
-![](./img/in_edit_mode.png)
-
-### Performing an action on a file
-
-The "Actions" dropdown appears on each file, and is used to perform actions on
-that specific file.
-
-*   Open - opens this file in the editor.
-*   Delete - deletes this file from the repo.
-*   Rename - renames this file in the repo.
-*   Restore - restores this file to the state it existed in at the patch the
-edit was created on.
-
-![](./img/actions_overflow.png)
-
-### Modifying the file
-
-This is the editor view.
-
-Clicking on the file path allows you to rename the file, You can edit code in
-the textarea, and "Close" will discard any unsaved changes and navigate back to
-the previous view.
-
-![](./img/in_editor.png)
-
-### Saving the edit
-
-You can save changes to the code with `cmd+s`, `ctrl+s`, or by clicking the
-"Save" button.
-
-![](./img/edit_made.png)
-
-### Publishing the edit
-
-You may publish or delete the edit by clicking the buttons in the header.
-
-
-
-![](./img/edit_pending.png)
-
-### What if I have questions not answered here?
-
-Gerrit's [official docs](https://gerrit-review.googlesource.com/Documentation/user-inline-edit.html)
-are in the process of being updated and largely refer to the old UI, but the
-user experience is largely the same.
-
-Otherwise, please email
-[the repo-discuss mailing list](mailto:repo-discuss@google.com) or file a bug
-on Gerrit's official bug tracker,
-[Monorail](https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit+Issue).
\ No newline at end of file
diff --git a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png b/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
deleted file mode 100644
index bf39763..0000000
--- a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_made.png b/polygerrit-ui/edit-walkthrough/img/edit_made.png
deleted file mode 100644
index 658245d..0000000
--- a/polygerrit-ui/edit-walkthrough/img/edit_made.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_pending.png b/polygerrit-ui/edit-walkthrough/img/edit_pending.png
deleted file mode 100644
index a63f6ee..0000000
--- a/polygerrit-ui/edit-walkthrough/img/edit_pending.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png b/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
deleted file mode 100644
index 582ed66..0000000
--- a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_editor.png b/polygerrit-ui/edit-walkthrough/img/in_editor.png
deleted file mode 100644
index 228d020..0000000
--- a/polygerrit-ui/edit-walkthrough/img/in_editor.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/into_edit.png b/polygerrit-ui/edit-walkthrough/img/into_edit.png
deleted file mode 100644
index b6c14ed..0000000
--- a/polygerrit-ui/edit-walkthrough/img/into_edit.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 2734f58..e030878 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -41,12 +41,11 @@
 )
 
 var (
-	plugins               = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port                  = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
-	host                  = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	scheme                = flag.String("scheme", "https", "URL scheme")
-	cdnPattern            = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
-	bundledPluginsPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_assets/[0-9.]*")
+	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
+	port       = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
+	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	scheme     = flag.String("scheme", "https", "URL scheme")
+	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
 )
 
 func main() {
@@ -121,8 +120,8 @@
 
 func addDevHeaders(writer http.ResponseWriter) {
 	writer.Header().Set("Access-Control-Allow-Origin", "*")
+	writer.Header().Set("Access-Control-Allow-Headers", "cache-control,x-test-origin")
 	writer.Header().Set("Cache-Control", "public, max-age=10, must-revalidate")
-
 }
 
 func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
@@ -394,9 +393,6 @@
 	// contains window.INITIAL_DATA=...
 	// Here we rely on the fact that the <script> snippet that we want to append to is the first one.
 	if len(*plugins) > 0 {
-		// If the host page contains a reference to a plugin bundle that would be preloaded, then remove it.
-		replaced = bundledPluginsPattern.ReplaceAllString(replaced, "")
-
 		insertionPoint := strings.Index(replaced, "</script>")
 		builder := new(strings.Builder)
 		builder.WriteString(
diff --git a/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/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index 1241665..a9e0a44 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -65,6 +65,14 @@
   {call InboundEmailRejectionFooter /}
 {/template}
 
+{template InboundEmailRejection_CHANGE_NOT_FOUND kind="text"}
+  Gerrit Code Review was unable to process your email because the change was not found.
+  {\n}
+  Maybe the project doesn't exist or is not visible? Maybe the change is not visible or got
+  deleted?
+  {call InboundEmailRejectionFooter /}
+{/template}
+
 {template InboundEmailRejection_COMMENT_REJECTED kind="text"}
   Gerrit Code Review rejected one or more comments because they did not pass validation, or
   because the maximum number of comments per change would be exceeded.
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index 6937d13..3444b7f 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -82,6 +82,17 @@
   {call InboundEmailRejectionFooterHtml /}
 {/template}
 
+{template InboundEmailRejectionHtml_CHANGE_NOT_FOUND}
+  <p>
+    Gerrit Code Review was unable to process your email because the change was not found.
+  </p>
+  <p>
+    Maybe the project doesn't exist or is not visible? Maybe the change is not visible or got
+    deleted?
+  <p>
+  {call InboundEmailRejectionFooterHtml /}
+{/template}
+
 {template InboundEmailRejectionHtml_COMMENT_REJECTED}
   <p>
     Gerrit Code Review rejected one or more comments because they did not pass validation, or
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
index 0fe8835..aa2b946 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -23,23 +23,35 @@
 {template NewChange kind="text"}
   {@param change: ?}
   {@param email: ?}
+  {@param fromName: ?}
   {@param ownerName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
-  {if $email.reviewerNames}
-    Hello{sp}
-    {for $reviewerName in $email.reviewerNames}
-      {if not isFirst($reviewerName)},{sp}{/if}
-      {$reviewerName}
-    {/for},
+  {if $email.reviewerNames or $email.removedReviewerNames}
+   {if $email.reviewerNames}
+      Hello{sp}
+      {for $reviewerName in $email.reviewerNames}
+        {if not isFirst($reviewerName)},{sp}{/if}
+        {$reviewerName}
+      {/for},
 
-    {\n}
-    {\n}
+      {\n}
+      {\n}
 
-    I'd like you to do a code review.
-
+      I'd like you to do a code review.
+      {\n}
+    {/if}
+    {if $email.removedReviewerNames}
+      {$fromName} has removed{sp}
+      {for $reviewerName in $email.removedReviewerNames}
+        {if not isFirst($reviewerName)},{sp}{/if}
+        {$reviewerName}
+      {/for}{sp}
+      from this change.{sp}
+      {\n}
+    {/if}
     {if $email.changeUrl}
-      {sp}Please visit
+      Please visit
 
       {\n}
       {\n}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index dbb3d8a..272c3ef 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -26,16 +26,35 @@
   {@param patchSet: ?}
   {@param projectName: ?}
   <p>
-    {if $email.reviewerNames}
-      {$fromName} would like{sp}
-      {for $reviewerName in $email.reviewerNames}
-        {if not isFirst($reviewerName)}
-          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
-        {/if}
-        {$reviewerName}
-      {/for}{sp}
-      to <strong>review</strong> this change
-      {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+    {if $email.reviewerNames or $email.removedReviewerNames}
+      {if $email.reviewerNames}
+        {$fromName} would like{sp}
+        {for $reviewerName in $email.reviewerNames}
+          {if not isFirst($reviewerName)}
+            {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+          {/if}
+          {$reviewerName}
+        {/for}{sp}
+        to <strong>review</strong> this change
+        {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+        {\n}
+      {/if}
+      {if $email.removedReviewerNames}
+        <p>
+          {$fromName}{sp}
+          <strong>
+            removed{sp}
+            {for $reviewerName in $email.removedReviewerNames}
+              {if not isFirst($reviewerName)}
+                {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+              {/if}
+              {$reviewerName}
+            {/for}
+          </strong>{sp}
+          from this change.
+        </p>
+        {\n}
+      {/if}
     {else}
       {$ownerName} has uploaded this change for <strong>review</strong>.
     {/if}
diff --git a/resources/log4j.properties b/resources/log4j.properties
index 39246b3..2898cfc 100644
--- a/resources/log4j.properties
+++ b/resources/log4j.properties
@@ -41,11 +41,5 @@
 log4j.logger.org.openid4java.server.RealmVerifier=ERROR
 log4j.logger.org.openid4java.message.AuthSuccess=ERROR
 
-# Silence non-critical messages from c3p0 (if used).
-#
-log4j.logger.com.mchange.v2.c3p0=WARN
-log4j.logger.com.mchange.v2.resourcepool=WARN
-log4j.logger.com.mchange.v2.sql=WARN
-
 # Silence non-critical messages from apache.http
 log4j.logger.org.apache.http=WARN
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 04bacaf..03a29da 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,167 +1,9 @@
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 load("@npm//@bazel/terser:index.bzl", "terser_minified")
-load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
-NPMJS = "NPMJS"
-
-GERRIT = "GERRIT:"
-
-def _npm_tarball(name):
-    return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
-
-def _npm_binary_impl(ctx):
-    """rule to download a NPM archive."""
-    name = ctx.name
-    version = NPM_VERSIONS[name]
-    sha1 = NPM_SHA1S[name]
-
-    dir = "%s-%s" % (name, version)
-    filename = "%s.tgz" % dir
-    base = "%s@%s.npm_binary.tgz" % (name, version)
-    dest = ctx.path(base)
-    repository = ctx.attr.repository
-    if repository == GERRIT:
-        url = "https://gerrit-maven.storage.googleapis.com/npm-packages/%s" % filename
-    elif repository == NPMJS:
-        url = "https://registry.npmjs.org/%s/-/%s" % (name, filename)
-    else:
-        fail("repository %s not in {%s,%s}" % (repository, GERRIT, NPMJS))
-
-    python = ctx.which("python")
-    script = ctx.path(ctx.attr._download_script)
-
-    args = [python, script, "-o", dest, "-u", url, "-v", sha1]
-    out = ctx.execute(args)
-    if out.return_code:
-        fail("failed %s: %s" % (args, out.stderr))
-    ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
-
-npm_binary = repository_rule(
-    attrs = {
-        "repository": attr.string(default = NPMJS),
-        # Label resolves within repo of the .bzl file.
-        "_download_script": attr.label(default = Label("//tools:download_file.py")),
-    },
-    local = True,
-    implementation = _npm_binary_impl,
-)
-
 ComponentInfo = provider()
 
-# for use in repo rules.
-def _run_npm_binary_str(ctx, tarball, args):
-    python_bin = ctx.which("python")
-    return " ".join([
-        str(python_bin),
-        str(ctx.path(ctx.attr._run_npm)),
-        str(ctx.path(tarball)),
-    ] + args)
-
-def _bower_archive(ctx):
-    """Download a bower package."""
-    download_name = "%s__download_bower.zip" % ctx.name
-    renamed_name = "%s__renamed.zip" % ctx.name
-    version_name = "%s__version.json" % ctx.name
-
-    cmd = [
-        ctx.which("python"),
-        ctx.path(ctx.attr._download_bower),
-        "-b",
-        "%s" % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
-        "-n",
-        ctx.name,
-        "-p",
-        ctx.attr.package,
-        "-v",
-        ctx.attr.version,
-        "-s",
-        ctx.attr.sha1,
-        "-o",
-        download_name,
-    ]
-
-    out = ctx.execute(cmd)
-    if out.return_code:
-        fail("failed %s: %s" % (cmd, out.stderr))
-
-    _bash(ctx, " && ".join([
-        "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
-        "TZ=UTC",
-        "export UTC",
-        "cd $TMP",
-        "mkdir bower_components",
-        "cd bower_components",
-        "unzip %s" % ctx.path(download_name),
-        "cd ..",
-        "find . -exec touch -t 198001010000 '{}' ';'",
-        "zip -Xr %s bower_components" % renamed_name,
-        "cd ..",
-        "rm -rf ${TMP}",
-    ]))
-
-    dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
-    ctx.file(
-        version_name,
-        '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version),
-    )
-    ctx.file(
-        "BUILD",
-        "\n".join([
-            "package(default_visibility=['//visibility:public'])",
-            "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
-            "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
-        ]),
-        False,
-    )
-
-def _bash(ctx, cmd):
-    cmd_list = ["bash", "-c", cmd]
-    out = ctx.execute(cmd_list)
-    if out.return_code:
-        fail("failed %s: %s" % (cmd_list, out.stderr))
-
-bower_archive = repository_rule(
-    _bower_archive,
-    attrs = {
-        "package": attr.string(mandatory = True),
-        "semver": attr.string(),
-        "sha1": attr.string(mandatory = True),
-        "version": attr.string(mandatory = True),
-        "_bower_archive": attr.label(default = Label("@bower//:%s" % _npm_tarball("bower"))),
-        "_download_bower": attr.label(default = Label("//tools/js:download_bower.py")),
-        "_run_npm": attr.label(default = Label("//tools/js:run_npm_binary.py")),
-    },
-)
-
-def _bower_component_impl(ctx):
-    transitive_zipfiles = depset(
-        direct = [ctx.file.zipfile],
-        transitive = [d[ComponentInfo].transitive_zipfiles for d in ctx.attr.deps],
-    )
-
-    transitive_licenses = depset(
-        direct = [ctx.file.license],
-        transitive = [d[ComponentInfo].transitive_licenses for d in ctx.attr.deps],
-    )
-
-    transitive_versions = depset(
-        direct = ctx.files.version_json,
-        transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps],
-    )
-
-    return [
-        ComponentInfo(
-            transitive_licenses = transitive_licenses,
-            transitive_versions = transitive_versions,
-            transitive_zipfiles = transitive_zipfiles,
-        ),
-    ]
-
-_common_attrs = {
-    "deps": attr.label_list(providers = [ComponentInfo]),
-}
-
 def _js_component(ctx):
     dir = ctx.outputs.zip.path + ".dir"
     name = ctx.outputs.zip.basename
@@ -182,7 +24,7 @@
         inputs = ctx.files.srcs,
         outputs = [ctx.outputs.zip],
         command = cmd,
-        mnemonic = "GenBowerZip",
+        mnemonic = "GenJsComponentZip",
     )
 
     licenses = []
@@ -199,248 +41,15 @@
 
 js_component = rule(
     _js_component,
-    attrs = dict(_common_attrs.items() + {
+    attrs = {
         "srcs": attr.label_list(allow_files = [".js"]),
         "license": attr.label(allow_single_file = True),
-    }.items()),
+    },
     outputs = {
         "zip": "%{name}.zip",
     },
 )
 
-_bower_component = rule(
-    _bower_component_impl,
-    attrs = dict(_common_attrs.items() + {
-        "license": attr.label(allow_single_file = True),
-
-        # If set, define by hand, and don't regenerate this entry in bower2bazel.
-        "seed": attr.bool(default = False),
-        "version_json": attr.label(allow_files = [".json"]),
-        "zipfile": attr.label(allow_single_file = [".zip"]),
-    }.items()),
-)
-
-# TODO(hanwen): make license mandatory.
-def bower_component(name, license = None, **kwargs):
-    prefix = "//lib:LICENSE-"
-    if license and not license.startswith(prefix):
-        license = prefix + license
-    _bower_component(
-        name = name,
-        license = license,
-        zipfile = "@%s//:zipfile" % name,
-        version_json = "@%s//:version_json" % name,
-        **kwargs
-    )
-
-def _bower_component_bundle_impl(ctx):
-    """A bunch of bower components zipped up."""
-    zips = depset()
-    for d in ctx.attr.deps:
-        files = d[ComponentInfo].transitive_zipfiles
-
-        # TODO(davido): Make sure the field always contains a depset
-        if type(files) == "list":
-            files = depset(files)
-        zips = depset(transitive = [zips, files])
-
-    versions = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
-
-    licenses = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
-
-    out_zip = ctx.outputs.zip
-    out_versions = ctx.outputs.version_json
-
-    ctx.actions.run_shell(
-        inputs = zips.to_list(),
-        outputs = [out_zip],
-        command = " && ".join([
-            "p=$PWD",
-            "TZ=UTC",
-            "export TZ",
-            "rm -rf %s.dir" % out_zip.path,
-            "mkdir -p %s.dir/bower_components" % out_zip.path,
-            "cd %s.dir/bower_components" % out_zip.path,
-            "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips.to_list()])),
-            "cd ..",
-            "find . -exec touch -t 198001010000 '{}' ';'",
-            "zip -Xqr $p/%s bower_components/*" % out_zip.path,
-        ]),
-        mnemonic = "BowerCombine",
-    )
-
-    ctx.actions.run_shell(
-        inputs = versions.to_list(),
-        outputs = [out_versions],
-        mnemonic = "BowerVersions",
-        command = "(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions.to_list()]), out_versions.path),
-    )
-
-    return [
-        ComponentInfo(
-            transitive_licenses = licenses,
-            transitive_versions = versions,
-            transitive_zipfiles = zips,
-        ),
-    ]
-
-bower_component_bundle = rule(
-    _bower_component_bundle_impl,
-    attrs = _common_attrs,
-    outputs = {
-        "version_json": "%{name}-versions.json",
-        "zip": "%{name}.zip",
-    },
-)
-
-def _bundle_impl(ctx):
-    """Groups a set of .html and .js together in a zip file.
-
-    Outputs:
-      NAME-versions.json:
-        a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
-        transitive dependencies.
-    NAME.zip:
-      a zip file containing the transitive dependencies for this bundle.
-    """
-
-    # intermediate artifact if split is wanted.
-    if ctx.attr.split:
-        bundled = ctx.actions.declare_file(ctx.outputs.html.path + ".bundled.html")
-    else:
-        bundled = ctx.outputs.html
-    destdir = ctx.outputs.html.path + ".dir"
-    zips = [z for d in ctx.attr.deps for z in d[ComponentInfo].transitive_zipfiles.to_list()]
-
-    # We are splitting off the package dir from the app.path such that
-    # we can set the package dir as the root for the bundler, which means
-    # that absolute imports are interpreted relative to that root.
-    pkg_dir = ctx.attr.pkg.lstrip("/")
-    app_path = ctx.file.app.path
-    app_path = app_path[app_path.index(pkg_dir) + len(pkg_dir):]
-
-    hermetic_npm_binary = " ".join([
-        "python",
-        "$p/" + ctx.file._run_npm.path,
-        "$p/" + ctx.file._bundler_archive.path,
-        "--inline-scripts",
-        "--inline-css",
-        "--sourcemaps",
-        "--strip-comments",
-        "--out-file",
-        "$p/" + bundled.path,
-        "--root",
-        pkg_dir,
-        app_path,
-    ])
-
-    cmd = " && ".join([
-        # unpack dependencies.
-        "export PATH",
-        "p=$PWD",
-        "rm -rf %s" % destdir,
-        "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
-        "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
-            " ".join([z.path for z in zips]),
-            destdir,
-            pkg_dir,
-        ),
-        "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
-        "cd %s" % destdir,
-        hermetic_npm_binary,
-    ])
-
-    # Node/NPM is not (yet) hermeticized, so we have to get the binary
-    # from the environment, and it may be under $HOME, so we can't run
-    # in the sandbox.
-    node_tweaks = dict(
-        execution_requirements = {"local": "1"},
-        use_default_shell_env = True,
-    )
-    ctx.actions.run_shell(
-        mnemonic = "Bundle",
-        inputs = [
-            ctx.file._run_npm,
-            ctx.file.app,
-            ctx.file._bundler_archive,
-        ] + list(zips) + ctx.files.srcs,
-        outputs = [bundled],
-        command = cmd,
-        **node_tweaks
-    )
-
-    if ctx.attr.split:
-        hermetic_npm_command = "export PATH && " + " ".join([
-            "python",
-            ctx.file._run_npm.path,
-            ctx.file._crisper_archive.path,
-            "--script-in-head=false",
-            "--always-write-script",
-            "--source",
-            bundled.path,
-            "--html",
-            ctx.outputs.html.path,
-            "--js",
-            ctx.outputs.js.path,
-        ])
-
-        ctx.actions.run_shell(
-            mnemonic = "Crisper",
-            inputs = [
-                ctx.file._run_npm,
-                ctx.file.app,
-                ctx.file._crisper_archive,
-                bundled,
-            ],
-            outputs = [ctx.outputs.js, ctx.outputs.html],
-            command = hermetic_npm_command,
-            **node_tweaks
-        )
-
-def _bundle_output_func(name, split):
-    _ignore = [name]  # unused.
-    out = {"html": "%{name}.html"}
-    if split:
-        out["js"] = "%{name}.js"
-    return out
-
-_bundle_rule = rule(
-    _bundle_impl,
-    attrs = {
-        "srcs": attr.label_list(allow_files = [
-            ".js",
-            ".html",
-            ".txt",
-            ".css",
-            ".ico",
-        ]),
-        "app": attr.label(
-            mandatory = True,
-            allow_single_file = True,
-        ),
-        "pkg": attr.string(mandatory = True),
-        "split": attr.bool(default = True),
-        "deps": attr.label_list(providers = [ComponentInfo]),
-        "_bundler_archive": attr.label(
-            default = Label("@polymer-bundler//:%s" % _npm_tarball("polymer-bundler")),
-            allow_single_file = True,
-        ),
-        "_crisper_archive": attr.label(
-            default = Label("@crisper//:%s" % _npm_tarball("crisper")),
-            allow_single_file = True,
-        ),
-        "_run_npm": attr.label(
-            default = Label("//tools/js:run_npm_binary.py"),
-            allow_single_file = True,
-        ),
-    },
-    outputs = _bundle_output_func,
-)
-
-def bundle_assets(*args, **kwargs):
-    """Combine html, js, css files and optionally split into js and html bundles."""
-    _bundle_rule(pkg = native.package_name(), *args, **kwargs)
-
 def polygerrit_plugin(name, app, plugin_name = None):
     """Produces plugin file set with minified javascript.
 
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 221ae2f..555aa17 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # reads bazel query XML files, to join target names with their licenses.
 
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index cdb13d0..7b1375a 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -50,7 +50,7 @@
     # post process the XML into our favorite format.
     native.genrule(
         name = "gen_license_txt_" + name,
-        cmd = "python $(location //tools/bzl:license-map.py) %s %s %s > $@" % (" ".join(opts), " ".join(json_maps_locations), " ".join(xmls)),
+        cmd = "python3 $(location //tools/bzl:license-map.py) %s %s %s > $@" % (" ".join(opts), " ".join(json_maps_locations), " ".join(xmls)),
         outs = [name + ".gen.txt"],
         tools = tools,
         **kwargs
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index d96ffc2..adea89e 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -141,7 +141,7 @@
     binjar_path = ctx.path("/".join(["jar", binjar]))
     binurl = url + ".jar"
 
-    python = ctx.which("python")
+    python = ctx.which("python3")
     script = ctx.path(ctx.attr._download_script)
 
     args = [python, script, "-o", binjar_path, "-u", binurl]
diff --git a/tools/download_file.py b/tools/download_file.py
index 936bcef..2af2c07 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2013 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index c7398a8..ef20ace 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Copyright (C) 2016 The Android Open Source Project
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/tools/js/BUILD b/tools/js/BUILD
index 1a272e2..d696496 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -1 +1 @@
-exports_files(["run_npm_binary.py", "eslint-chdir.js"])
+exports_files(["eslint-chdir.js"])
diff --git a/tools/js/bowerutil.py b/tools/js/bowerutil.py
deleted file mode 100644
index 9fb82af..0000000
--- a/tools/js/bowerutil.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Copyright (C) 2013 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import os
-
-
-def hash_bower_component(hash_obj, path):
-    """Hash the contents of a bower component directory.
-
-    This is a stable hash of a directory downloaded with `bower install`, minus
-    the .bower.json file, which is autogenerated each time by bower. Used in
-    lieu of hashing a zipfile of the contents, since zipfiles are difficult to
-    hash in a stable manner.
-
-    Args:
-      hash_obj: an open hash object, e.g. hashlib.sha1().
-      path: path to the directory to hash.
-
-    Returns:
-      The passed-in hash_obj.
-    """
-    if not os.path.isdir(path):
-        raise ValueError('Not a directory: %s' % path)
-
-    path = os.path.abspath(path)
-    for root, dirs, files in os.walk(path):
-        dirs.sort()
-        for f in sorted(files):
-            if f == '.bower.json':
-                continue
-            p = os.path.join(root, f)
-            hash_obj.update(p[len(path)+1:].encode("utf-8"))
-            hash_obj.update(open(p, "rb").read())
-
-    return hash_obj
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
deleted file mode 100755
index 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 00aeb47..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-rc5</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 2df031a..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-rc5</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 95fc0bf..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-rc5</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 6ea6cbf..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-rc5</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 f74b684..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-rc5"
+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"