Merge "Convert proto IndexedFields to bytes in Lucene and Fake index"
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 358324d..87f3851 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -108,6 +108,7 @@
 	Action used by Gerrit to submit an approved change to its
 	destination branch.  Supported options are:
 +
+* INHERIT: inherits the submit-type from the parent project.
 * FAST_FORWARD_ONLY: produces a strictly linear history.
 * MERGE_IF_NECESSARY: create a merge commit when required.
 * REBASE_IF_NECESSARY: rebase the commit when required.
@@ -116,7 +117,7 @@
 * CHERRY_PICK: always cherry-pick the commit.
 
 +
-Defaults to MERGE_IF_NECESSARY unless
+Defaults to INHERIT unless
 link:config-gerrit.html#repository.name.defaultSubmitType[
 repository.<name>.defaultSubmitType] is set to a different value.
 For more details see link:config-project-config.html#submit-type[
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 7a0e305..31008f6 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -736,6 +736,15 @@
 the parent project. If the property is not set in any parent project, the
 default value is `FALSE`.
 
+[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
++
+Whether to skip adding the Git commit author and committer as reviewers for
+a new change.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
+
 [[file-groups]]
 == The file +groups+
 
diff --git a/Documentation/images/browser-notification-example.png b/Documentation/images/browser-notification-example.png
new file mode 100644
index 0000000..2b60054
--- /dev/null
+++ b/Documentation/images/browser-notification-example.png
Binary files differ
diff --git a/Documentation/images/browser-notification-preference.png b/Documentation/images/browser-notification-preference.png
new file mode 100644
index 0000000..57d5fd6
--- /dev/null
+++ b/Documentation/images/browser-notification-preference.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-apply-fix.png b/Documentation/images/user-review-ui-apply-fix.png
new file mode 100644
index 0000000..d838d48
--- /dev/null
+++ b/Documentation/images/user-review-ui-apply-fix.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-metadata.png b/Documentation/images/user-review-ui-change-metadata.png
new file mode 100644
index 0000000..23abc07
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-metadata.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-annotated.png b/Documentation/images/user-review-ui-change-screen-annotated.png
index 5c3f80a..4e12c96 100644
--- a/Documentation/images/user-review-ui-change-screen-annotated.png
+++ b/Documentation/images/user-review-ui-change-screen-annotated.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
deleted file mode 100644
index 61e2b25..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-comments-tab.png b/Documentation/images/user-review-ui-change-screen-comments-tab.png
new file mode 100644
index 0000000..d522f60
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-comments-tab.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list.png b/Documentation/images/user-review-ui-change-screen-file-list.png
index 721b229..b0c2af3 100644
--- a/Documentation/images/user-review-ui-change-screen-file-list.png
+++ b/Documentation/images/user-review-ui-change-screen-file-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 9ef8f27..224de2d 100644
--- a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
+++ b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
index 1c50fc5..201db13 100644
--- a/Documentation/images/user-review-ui-change-screen-reply.png
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-topleft.png b/Documentation/images/user-review-ui-change-screen-topleft.png
index a1f7813..b3bf8e7f 100644
--- a/Documentation/images/user-review-ui-change-screen-topleft.png
+++ b/Documentation/images/user-review-ui-change-screen-topleft.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen.png b/Documentation/images/user-review-ui-change-screen.png
index ff2570b..98a5d6d 100644
--- a/Documentation/images/user-review-ui-change-screen.png
+++ b/Documentation/images/user-review-ui-change-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-copy-links.png b/Documentation/images/user-review-ui-copy-links.png
new file mode 100644
index 0000000..f8fa114
--- /dev/null
+++ b/Documentation/images/user-review-ui-copy-links.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
index 047034c..98cf7af 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
index 74d02e3..ebdd177 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-submit-requirements.png b/Documentation/images/user-review-ui-submit-requirements.png
new file mode 100644
index 0000000..e4b88c1
--- /dev/null
+++ b/Documentation/images/user-review-ui-submit-requirements.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-suggest-fix.png b/Documentation/images/user-review-ui-suggest-fix.png
new file mode 100644
index 0000000..e08fb26
--- /dev/null
+++ b/Documentation/images/user-review-ui-suggest-fix.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index c3d79b1..89b88aa 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -24,7 +24,7 @@
 
 == Tutorials
 . Web
-.. link:user-review-ui.html[Reviewing Changes]
+.. link:user-review-ui.html[Review UI Overview]
 .. link:user-search.html[Searching Changes]
 .. link:user-inline-edit.html[Manipulating Changes in Browser]
 .. link:user-notify.html[Subscribing to Email Notifications]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 94db15e..9e71df7 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3896,17 +3896,19 @@
 Map with the comment link configurations of the project. The name of
 the comment link configuration is mapped to a link:#commentlink-info[
 CommentlinkInfo] entity.
-|`plugin_config`                           |optional|
+|`plugin_config`                                    |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
 entities. Only filled for users who have read access to `refs/meta/config`.
-|`actions`                                 |optional|
+|`actions`                                          |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
-|`reject_empty_commit`                     |optional|
+|`reject_empty_commit`                              |optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 empty commits should be rejected when a change is merged.
 link:rest-api-changes.html#action-info[ActionInfo] entities.
+|`skip_adding_author_and_committer_as_reviewers`    |optional|
+Whether to skip adding the Git commit author and committer as reviewers for a new change.
 |=======================================================
 
 [[config-input]]
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 0a7a77c..c4b8aa7 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -134,6 +134,21 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
+=== Browser notifications
+
+You'll automatically get notifications when you are in the attention set. You
+must enable desktop notifications on your browser to see them.
+
+image::images/browser-notification-example.png["browser notification example", align="center"]
+
+You can turn off automatic notifications in user preferences. They are enabled
+by default.
+
+image::images/browser-notification-preference.png["user preference for browser notifications", align="center"]
+
+Current implementation works only when gerrit is open in one of the tabs. We
+poll every 5 minutes for changes in attention set.
+
 === Bold Changes / Mark Reviewed
 
 Before the attention set feature, changes were bolded in the dashboard when
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 6f5f729..73668d7 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -1,21 +1,15 @@
 :linkattrs:
-= Review UI
+= Review UI Overview
 
 Reviewing changes is an important task and the Gerrit Web UI provides
 many functionalities to make the review process comfortable and
 efficient.
 
-The UI has three different main views,
-
-** The dashboard, which shows all changes that are relevant to you
-** The change screen, which shows the change with all its metadata
-** The diff view, which shows changes to a single file
-
 [[change-screen]]
 == Change Screen
 
-The change screen shows the details of a single change and provides
-various actions on it.
+The change screen is the main view for a change. It shows the details of a
+single change and allows various actions on it.
 
 image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
 
@@ -28,44 +22,81 @@
 
 Top left, you find the status of the change, and a permalink.
 
-image::images/user-review-ui-change-screen-topleft.png[width=400, link="images/user-review-ui-change-screen-topleft.png"]
+image::images/user-review-ui-change-screen-topleft.png[width=600, link="images/user-review-ui-change-screen-topleft.png"]
 
 [[change-status]]
 The change status shows the state of the change:
 
-- [[active]]`Active`:
+- `Active`:
 +
 The change is under active review.
 
-- [[merge-conflict]]`Merge Conflict`:
+- `Merge Conflict`:
 +
-The change can't be merged due to conflicts.
+The change can't be merged into the destination branch due to conflicts.
 
-- [[ready-to-submit]]`Ready to Submit`:
+- `Ready to Submit`:
 +
-The change has all necessary approvals and may be submitted.
+The change has all necessary approvals and fulfils all other submit
+requirements. It can be submitted.
 
-- [[merged]]`Merged`:
+- `Merged`:
 +
 The change was successfully merged into the destination branch.
 
-- [[abandoned]]`Abandoned`:
+- `Abandoned`:
 +
-The change was abandoned.
+The change was abandoned. It is not intended to be updated, reviewed or
+submitted anymore.
+
+- `Private`:
++
+The change is marked as link:intro-user.html#private-changes[Private]. And has
+reduced visibility.
+
+- `Revert Created|Revert Submitted`:
++
+The change has a corresponding revert change. Revert changes can be created
+through UI (see <<actions, Actions section>>).
+
+- `WIP`:
++
+The change was marked as "Work in Progress". For example to indicate to
+reviewers that they shouldn't review the change yet.
 
 [[star]]
 === Star Change
 
-Clicking the star icon marks the change as a favorite: it turns on
+Clicking the star icon bookmarks the change: it turns on
 email notifications for this change, and the change is added to the
 list under `Your` > `Starred Changes`. They can be queried by the
 link:user-search.html#is[is:starred] search operator.
 
+[[quick-links]]
+=== Links Menu
+
+Links menu contains various change related strings for quick copying. Such as:
+Change Number, URL, Title+Url, etc. The lines in this menu can also be accessed
+via shortcuts for convenience.
+
+image::images/user-review-ui-copy-links.png[width=600, link="images/user-review-ui-copy-links.png"]
+
 [[change-info]]
 === Change metadata
 
-The change metadata block contains detailed information about the change
-and offers actions on the change.
+The change metadata block contains detailed information about the change.
+
+image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
+
+- [[owner]]Owner/Uploader/Author/Committer
++
+Owner is the person who created the change
++
+Uploader is the person who uploaded the latest patchset (the patchset that will
+be merged if the change is submitted)
++
+Author/Committer are concepts from Git and are retrieved from the commit when
+it's sent for review.
 
 - [[reviewers]]Reviewers:
 +
@@ -74,16 +105,36 @@
 For each reviewer there is a tooltip that shows on which labels the
 reviewer is allowed to vote.
 +
-New reviewers can be added by clicking on the pencil icon. Typing
-into the pop-up text field activates auto completion of user and group
-names.
+New reviewers can be added through reply dialog that is opened by clicking on
+the pencil icon or on "Reply" button. Typing into the reviewer text field
+activates auto completion of user and group names.
 +
+
+- [[cc-list]]CC:
++
+Accounts in CC receive notifications for the updates on the change, but don't
+need to vote/review. If the CC'ed user votes they are moved to reviewers.
++
+
+- [[attention-set]]Attention set:
++
+Users in attention set are marked by "chevron" symbol (see screenshot above).
+The mark indicates that there are actions their attention is required on the
+change: Something updated/changed since last review, their vote is required,
+etc.
++
+Changes for which you are currently in attention set can be found using
+`attention:<User>` in search and show up in a separate category of personal
+dashboard.
++
+Clicking on the mark removes the user from attention set.
+
+
 [[remove-reviewer]]
-Reviewers can be removed from the change by clicking on the `x` icon
-in the reviewer's chip token. Removing a reviewer also removes the
-current votes of the reviewer. The removal of votes is recorded as a
-message on the change.
-+
+Reviewers can be removed from the change by selecting the appropriate option on
+the chip's hovercard. Removing a reviewer also removes current votes of the
+reviewer. The removal of votes is recorded in the change log.
+
 Removing reviewers is protected by permissions:
 
 ** Users can always remove themselves.
@@ -92,10 +143,7 @@
    Remove Reviewer] access right, the branch owner, the project owner
    and Gerrit administrators may remove anyone.
 
-+
-image::images/user-review-ui-change-screen-info-reviewers.png[width=600, link="images/user-review-ui-change-screen-reviewers.png"]
-
-- [[project-branch-topic]]Project / Branch / Topic:
+- [[repo-branch-topic]]Project (Repo) / Branch / Topic:
 +
 The name of the project for which the change was done is displayed as a
 link to the link:user-dashboards.html#project-default-dashboard[default
@@ -112,15 +160,55 @@
 access right. To be able to set a topic on a closed change, the
 `Edit Topic Name` must be assigned with the `force` flag.
 
+- [[parent]]Parent:
++
+Parent commit of the latest uploaded patchset. Or if the change has been merged
+the parent of the commit it was merged as into the destination branch.
+
+- [[merged-as]]Merged As:
++
+The SHA of the commit corresponding to the merged change on the destination
+branch.
+
+- [[revert-created-as]]Revert (Created|Submitted) As
++
+Points to the revert change, if one was created.
+
+- [[cherry-pick-of]]Cherry-pick of
++
+If the change was created as cherry-pick of some other change to a different
+branch, points to the original change.
+
 - [[submit-strategy]]Submit Strategy:
 +
 The link:project-setup.html#submit_type[submit strategy] that will be
 used to submit the change. The submit strategy is only displayed for
 open changes.
 
-- [[actions]]Actions:
+- [[hastags]]Hashtags:
 +
-Actions buttons are at the top, and in the overflow menu.
+Arbitrary string hashtags, that can be used to categorize changes and later use
+hashtags for search queries.
+
+[[submit-requirements]]
+=== Submit Requirements
+
+image::images/user-review-ui-submit-requirements.png[width=600, link="images/user-review-ui-copy-links.png"]
+
+Submit Requirements describe various conditions that must be fulfilled before
+the change can be submitted. Hovering over the requirement will show the
+description of the requirement, as well as additional information, such as:
+corresponding expression that is being evaluated, who can vote on the related
+labels etc.
+
+Approving votes are colored green; negative votes are colored red.
+
+For more detail on Submit Requirements see
+link:config-submit-requirements.html[Submit Requirement Configuration] page.
+
+[[actions]]
+=== Actions:
+Actions buttons are at the top right and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
 
@@ -220,13 +308,7 @@
 +
 image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
-- [[labels]]Labels & Votes:
-+
-Approving votes are colored green; negative votes are colored red.
-+
-image::images/user-review-ui-change-screen-change-info-labels.png[width=400, link="images/user-review-ui-change-screen-change-info-labels.png"]
-
-[[files]]
+[[files-tab]]
 === File List
 
 The file list shows the files that are modified in the currently viewed
@@ -251,17 +333,40 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+Every file is accompanied by a number of extra information, such as status
+(modified, added, deleted, etc.), number of changed lines, type (executable,
+link, plain), comments and others. Hovering over most icons and columns reveals
+additional information.
+
+Each file can be expanded to view the contents of the file and diff. For more
+information see <<diff-view, Diff View>> section.
+
+[[comments-tab]]
+=== Comments Tab
+
+Instead of the file list, a comments tab can be selected. Comments tab presents
+comments along with related file/diff snippets. It also offers some filtering
+opportunities at the top (ex. only unresolved, only comments from user X, etc.)
+
+image::images/user-review-ui-change-screen-comments-tab.png[width=800, link="images/user-review-ui-change-screen-comments-tab.png"]
+
+[[checks-tab]]
+=== Checks Tab
+Checks tab contains results of different "Check Runs" installed by plugins. For
+more information see link:pg-plugin-checks-api.html[Checks API] page.
 
 [[patch-sets]]
 === Patch Sets
 
-The change screen only presents one patch set at a time. Which patch
-set is currently viewed can be seen from the `Patch Sets` drop-down
-panel in the change header.
+The change screen only presents one pair of patch sets (`Patchset A` and
+`Patchset B`) at a time. `A` is always an earlier upload than `B` and serves as
+a base for diffing when viewing changes in the files. Which patch
+sets is currently viewed can be seen from the `Patch Sets` drop-down
+panel in the change header. If patchset 'A' is not selected a parent commit of
+patchset 'B' is used by default.
 
 image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
-
 [[download]]
 === Download
 
@@ -278,7 +383,8 @@
 
 Each command has a copy-to-clipboard icon that allows the command to be
 copied into the clipboard. This makes it easy to paste and execute the
-command on a Git command line.
+command on a Git command line. Additionally each line can copied to clipboard
+using number (1..9) of the appropriate line as a keyboard shortcut.
 
 If several download schemes are configured on the server (e.g. SSH and
 HTTP) there is a drop-down list to switch between the download schemes.
@@ -306,22 +412,20 @@
 
 image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
 
-
-
 [[related-changes]]
 === Related Changes
 
 If there are changes that are related to the currently viewed change
 they are displayed in the third column of the change screen.
 
-There are several lists of related changes and a tab control is used to
-display each list of related changes in its own tab.
+There are several lists of related changes that are displayed in separate
+sectionsunder each other.
 
-The following tabs may be displayed:
+The following sections may be displayed:
 
-- [[related-changes-tab]]`Related Changes`:
+- [[related-changes-section]]`Related Changes`:
 +
-This tab page shows changes on which the current change depends
+This section shows changes on which the current change depends
 (ancestors) and open changes that depend on the current change
 (descendants). For merge commits it also shows the closed changes that
 will be merged into the destination branch by submitting the merge
@@ -341,10 +445,10 @@
 +
 ** [[not-current]]Not current:
 +
-The selected patch set of the change is outdated; it is not the current
-patch set of the change.
+The patch set of the related change which is related to the current change is
+outdated; it is not the current patch set of the change.
 +
-It means that the
+For ancestor it means that the
 currently viewed patch set depends on a outdated patch set of the
 ancestor change. This is because a new patch set for the ancestor
 change was uploaded in the meantime and as result the currently viewed
@@ -364,20 +468,24 @@
 note that following the link to an indirect descendant change may
 result in a completely different related changes listing.
 
-** [[closed-ancestor]]Closed ancestor:
+** [[merged-related-change]]Merged
 +
-Indicates a closed ancestor, e.g. the commit was directly pushed into
-the repository bypassing code review, or the ancestor change was
-reviewed and submitted on another branch. The latter may indicate that
-the user has accidentally pushed the commit to the wrong branch, e.g.
-the commit was done on `branch-a`, but was then pushed to
-`refs/for/branch-b`.
+The change has been  merged.
++
+If the relationship to submitted change falls under conditions described in
+<<not-current, Not current>> the status is orange. Such changes can appear as
+both ancestors and descendants of the change.
+
+** [[submittable-related-change]]Submittable
++
+All the submit requirements are fulfilled for the related change and it can be
+submitted when all of its ancestors are submitted.
 
 ** [[closed-ancestor-abandoned]]Abandoned:
 +
 Indicates an abandoned change.
 
-- [[conflicts-with]]`Conflicts With`:
+- [[conflicts-with]]`Merge Conflicts`:
 +
 This section shows changes that conflict with the current change.
 Non-mergeable changes are filtered out; only conflicting changes that
@@ -393,10 +501,9 @@
 currently viewed change, when clicking the submit button. It includes
 ancestors of the current patch set.
 +
-This may include changes and its ancestors with the same topic if
-`change.submitWholeTopic` is enabled. Only open changes with the
-same topic are included in the list.
-+
+If `change.submitWholeTopic` is enabled this section also includes changes with
+the same topic. The list recursively includes all changes that can be reached by
+ancestor and topic relationships. Only open changes are included in the result.
 
 - [[cherry-picks]]`Cherry-Picks`:
 +
@@ -411,12 +518,18 @@
 
 If there are no related changes for a tab, the tab is not displayed.
 
+- [[same-topic]]`Same Topic`:
++
+This section shows changes which are part of the same topic. If
+`change.submitWholeTopic` is enabled, then this section is omitted and changes
+are included as part of <<submitted-together, `Submitted Together`>>
+
 [[reply]]
 === Reply
 
 The `Reply...` button in the change header allows to reply to the
 currently viewed patch set; one can add a summary comment, publish
-inline draft comments, and vote on the labels.
+inline draft comments, vote on the labels and adjust attention set.
 
 image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
 
@@ -424,10 +537,8 @@
 
 [[summary-comment]]
 A text box allows to type a summary comment for the currently viewed
-patch set. Some basic markdown-like syntax is supported which renders
-indented lines preformatted, lines starting with "- " or "* " as list
-items, and lines starting with "> " as block quotes (also see replying to
-link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
+patch set. Markdown syntax is supported same as in other
+<<comments-markdown, Comments>>.
 
 [[vote]]
 If the current patch set is viewed, buttons are displayed for
@@ -439,7 +550,7 @@
 are links to navigate to the inline comments which can be used if a
 comment needs to be edited.
 
-The `Post` button publishes the comments and the votes.
+The `SEND` button publishes the comments and the votes.
 
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
@@ -460,12 +571,12 @@
 
 image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
-[[history]]
-=== History
+[[change-log]]
+=== Change Log
 
 The history of the change can be seen in the lower part of the screen.
 
-The history contains messages for all kinds of change updates, e.g. a
+The log contains messages for all kinds of change updates, e.g. a
 message is added when a new patch set is uploaded or when a review was
 done.
 
@@ -491,12 +602,12 @@
 
 image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
-[[side-by-side]]
+[[diff-view]]
 == Side-by-Side Diff Screen
 
-The side-by-side diff screen shows a single patch; the old file version
-is displayed on the left side of the screen; the new file version is
-displayed on the right side of the screen.
+The side-by-side diff screen shows a single patch (or difference between two
+patchsets); the old file version is displayed on the left side of the screen;
+the new file version is displayed on the right side of the screen.
 
 This screen allows to review a patch and to comment on it.
 
@@ -557,6 +668,10 @@
 Code blocks with comments may overlap. This means it is possible to
 attach several comments to the same code.
 
+[[comments-markdown]]
+The comments support markdown. It follows the CommonMark spec, except inline
+images and direct HTML are not rendered and kept as plaintext.
+
 [[line-links]]
 The lines of the patch file are linkable: simply append
 '#<linenumber>' to the URL, or click on the line-number. This not only
@@ -565,15 +680,14 @@
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
 
-Quoting is supported, but only by manually copying & pasting the old
-comment that should be quoted and prefixing every line by "> ". Please
-note that for a correct rendering it is important to leave a blank line
-between a quoted block and the reply to it.
+Previous comment can be quoted using "Quote" button. A new draft would be open
+on the same comment thread with the text of the previoused comment quoted using
+markdown syntax.
 
 image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
 
 Comments are first saved as drafts, and you can revisit the drafts as
-you read through code review. Finally, they should be published by
+you read through code review. Finally, they will be published by
 clicking the "Reply".
 
 [[done]]
@@ -610,6 +724,21 @@
 make it visible to other users it must be published from the change
 screen by link:#reply[replying] to the change.
 
+[[suggest-fix]]
+=== Suggest fix (WIP)
+Comments can contain suggested fixes.
+
+Clicking "Suggest Fix" will insert a special code-block in the text of the
+comment. The contents of this code block will replace the lines the comment is
+attached to (what gets highlighted when hovering over comment).
+
+image::images/user-review-ui-suggest-fix.png[width=400, link="images/user-review-ui-suggest-fix.png"]
+
+The author of the change can then preview and apply the change. This will created
+a new patchset with changes applied.
+
+image::images/user-review-ui-apply-fix.png[width=800, link="images/user-review-ui-apply-fix.png"]
+
 [[file-level-comments]]
 === File Level Comments
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 3e48eec..5b4a9e5 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1080,6 +1080,19 @@
     }
   }
 
+  protected void setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean value)
+      throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.updateProject(
+          p ->
+              p.setBooleanConfig(
+                  BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS, value));
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+    }
+  }
+
   protected void blockAnonymousRead() throws Exception {
     String allRefs = RefNames.REFS + "*";
     projectOperations
diff --git a/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
index 4a676e6..23e4a23 100644
--- a/java/com/google/gerrit/common/RawInputUtil.java
+++ b/java/com/google/gerrit/common/RawInputUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
@@ -31,7 +30,6 @@
 
   public static RawInput create(byte[] bytes, String contentType) {
     requireNonNull(bytes);
-    checkArgument(bytes.length > 0);
     return new RawInput() {
       @Override
       public InputStream getInputStream() throws IOException {
diff --git a/java/com/google/gerrit/entities/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
index 5201f6d..605c40c 100644
--- a/java/com/google/gerrit/entities/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -41,7 +41,9 @@
   ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
   MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
   REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
-  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
+  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault"),
+  SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS(
+      "reviewer", "skipAddingAuthorAndCommitterAsReviewers");
 
   // Git config
   private final String section;
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
index f361741..d49ab0f 100644
--- a/java/com/google/gerrit/entities/LabelFunction.java
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRecord.Label;
 import java.util.Collections;
@@ -48,6 +49,16 @@
     ALL = Collections.unmodifiableMap(all);
   }
 
+  public static final Map<String, LabelFunction> ALL_NON_DEPRECATED;
+
+  static {
+    Map<String, LabelFunction> allNonDeprecated = new LinkedHashMap<>();
+    for (LabelFunction f : ImmutableSet.of(NO_BLOCK, NO_OP, PATCH_SET_LOCK)) {
+      allNonDeprecated.put(f.getFunctionName(), f);
+    }
+    ALL_NON_DEPRECATED = Collections.unmodifiableMap(allNonDeprecated);
+  }
+
   public static Optional<LabelFunction> parse(@Nullable String str) {
     return Optional.ofNullable(ALL.get(str));
   }
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index e3e3a57..6d2fa32 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -278,7 +278,10 @@
 
     public Permission build() {
       setRules(
-          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+          rulesBuilders.stream()
+              .map(PermissionRule.Builder::build)
+              .distinct()
+              .collect(toImmutableList()));
       return autoBuild();
     }
 
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
index 9a2d31e..1665c1c 100644
--- a/java/com/google/gerrit/entities/PermissionRule.java
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -202,6 +202,9 @@
         int dotdot = range.indexOf("..");
         int min = parseInt(range.substring(0, dotdot));
         int max = parseInt(range.substring(dotdot + 2));
+        if (min > max) {
+          throw new IllegalArgumentException("Invalid range in rule: " + orig);
+        }
         rule.setRange(min, max);
       } else {
         throw new IllegalArgumentException("Invalid range in rule: " + orig);
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index c24227d..fbb2fd7 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -197,8 +197,6 @@
 
     @AutoValue.Builder
     public abstract static class Builder {
-      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
-
       protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
 
       public abstract Builder predicateString(String value);
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 3ba1277..1a51c15 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -40,6 +40,7 @@
   public InheritedBooleanInfo enableReviewerByEmail;
   public InheritedBooleanInfo matchAuthorToCommitterDate;
   public InheritedBooleanInfo rejectEmptyCommit;
+  public InheritedBooleanInfo skipAddingAuthorAndCommitterAsReviewers;
 
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   @Deprecated // Equivalent to defaultSubmitType.value
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 8005fc5..906fc4c 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -34,6 +34,7 @@
   public InheritableBoolean enableReviewerByEmail;
   public InheritableBoolean matchAuthorToCommitterDate;
   public InheritableBoolean rejectEmptyCommit;
+  public InheritableBoolean skipAddingAuthorAndCommitterAsReviewers;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
index 74b82f5..99004bb 100644
--- a/java/com/google/gerrit/index/IndexedField.java
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -369,7 +369,12 @@
   /** Optional description of the field data. */
   public abstract Optional<String> description();
 
-  /** True if this field is mandatory. Default is false. */
+  /**
+   * True if this field is mandatory. Default is false.
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce that the field is required.
+   */
   public abstract boolean required();
 
   /** Allow reading the actual data from the index. Default is false. */
@@ -382,6 +387,9 @@
    * Optional size constrain on the field. The size is not constrained if this property is {@link
    * Optional#empty()}
    *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce the size.
+   *
    * <p>If the field is {@link #repeatable()}, the constraint applies to each element separately.
    */
   public abstract Optional<Integer> size();
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 9f07cab..25d7cf3 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -209,7 +209,10 @@
     return indexedFields;
   }
 
-  /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
+  /**
+   * Returns names of {@link SchemaField} fields in this schema where {@link SchemaField#isStored()}
+   * is true.
+   */
   public final ImmutableSet<String> getStoredFields() {
     return storedFields;
   }
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
index 7a120b7..51440fb 100644
--- a/java/com/google/gerrit/index/testing/TestIndexedFields.java
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -164,6 +164,12 @@
   public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
       STRING_FIELD.fullText("string_test");
 
+  public static final IndexedField<TestIndexedData, String>.SearchSpec PREFIX_STRING_FIELD_SPEC =
+      STRING_FIELD.prefix("prefix_string_test");
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec EXACT_STRING_FIELD_SPEC =
+      STRING_FIELD.exact("exact_string_test");
+
   public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
       IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
           .stored()
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index 059445e..cd2d79e 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -85,7 +86,7 @@
      *   <li>the approval is not overridden by a current approval on the patch set
      * </ul>
      */
-    public abstract ImmutableSet<PatchSetApproval> copiedApprovals();
+    public abstract ImmutableSet<PatchSetApprovalData> copiedApprovals();
 
     /**
      * Approvals on the previous patch set that have not been copied to the patch set.
@@ -96,7 +97,7 @@
      * <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
      * sets that were outdated before are not included.
      */
-    public abstract ImmutableSet<PatchSetApproval> outdatedApprovals();
+    public abstract ImmutableSet<PatchSetApprovalData> outdatedApprovals();
 
     static Result empty() {
       return create(
@@ -105,10 +106,68 @@
 
     @VisibleForTesting
     public static Result create(
-        ImmutableSet<PatchSetApproval> copiedApprovals,
-        ImmutableSet<PatchSetApproval> outdatedApprovals) {
+        ImmutableSet<PatchSetApprovalData> copiedApprovals,
+        ImmutableSet<PatchSetApprovalData> outdatedApprovals) {
       return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
     }
+
+    /**
+     * A {@link PatchSetApproval} with information about which atoms of the copy condition are
+     * passing/failing.
+     */
+    @AutoValue
+    public abstract static class PatchSetApprovalData {
+      /** The approval. */
+      public abstract PatchSetApproval patchSetApproval();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are fulfilled.
+       *
+       * <p>Example: The expression
+       *
+       * <pre>
+       * changekind:TRIVIAL_REBASE OR is:MIN
+       * </pre>
+       *
+       * has two leaf predicates:
+       *
+       * <ul>
+       *   <li>changekind:TRIVIAL_REBASE
+       *   <li>is:MIN
+       * </ul>
+       *
+       * This method will return the leaf predicates that are fulfilled, for example if only the
+       * first predicate is fulfilled, the returned list will be equal to
+       * ["changekind:TRIVIAL_REBASE"].
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> passingAtoms();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+       * #passingAtoms()} for more details.
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> failingAtoms();
+
+      @VisibleForTesting
+      public static PatchSetApprovalData create(
+          PatchSetApproval approval,
+          ImmutableSet<String> passingAtoms,
+          ImmutableSet<String> failingAtoms) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, passingAtoms, failingAtoms);
+      }
+
+      private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, ImmutableSet.of(), ImmutableSet.of());
+      }
+    }
   }
 
   private final GitRepositoryManager repoManager;
@@ -227,17 +286,18 @@
                 followUpPatchSet.commitId());
         boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
 
-        if (canCopy(
-            changeNotes,
-            priorPatchSet.id(),
-            followUpPatchSet,
-            approverId,
-            labelType.get(),
-            approvalValue,
-            changeKind,
-            isMerge,
-            revWalk,
-            repo.getConfig())) {
+        if (computeCopyResult(
+                changeNotes,
+                priorPatchSet.id(),
+                followUpPatchSet,
+                approverId,
+                labelType.get(),
+                approvalValue,
+                changeKind,
+                isMerge,
+                revWalk,
+                repo.getConfig())
+            .canCopy()) {
           targetPatchSetsBuilder.add(followUpPatchSetId);
         } else {
           // The approval is not copyable to this follow-up patch set.
@@ -251,7 +311,14 @@
     return targetPatchSetsBuilder.build();
   }
 
-  private boolean canCopy(
+  /**
+   * Checks whether a given approval can be copied from the given source patch set to the given
+   * target patch set.
+   *
+   * <p>The returned result also informs about which atoms of the copy condition are
+   * passing/failing.
+   */
+  private ApprovalCopyResult computeCopyResult(
       ChangeNotes changeNotes,
       PatchSet.Id sourcePatchSetId,
       PatchSet targetPatchSet,
@@ -263,7 +330,7 @@
       RevWalk revWalk,
       Config repoConfig) {
     if (!labelType.getCopyCondition().isPresent()) {
-      return false;
+      return ApprovalCopyResult.createForMissingCopyCondition();
     }
     ApprovalContext ctx =
         ApprovalContext.create(
@@ -283,15 +350,18 @@
       // request (e.g. a group used in this query might not be visible to the person sending this
       // request).
       try (ManualRequestContext ignored = requestContext.open()) {
-        return approvalQueryBuilder
-            .parse(labelType.getCopyCondition().get())
-            .asMatchable()
-            .match(ctx);
+        Predicate<ApprovalContext> copyConditionPredicate =
+            approvalQueryBuilder.parse(labelType.getCopyCondition().get());
+        boolean canCopy = copyConditionPredicate.asMatchable().match(ctx);
+        ImmutableSet.Builder<String> passingAtoms = ImmutableSet.builder();
+        ImmutableSet.Builder<String> failingAtoms = ImmutableSet.builder();
+        evaluateAtoms(copyConditionPredicate, ctx, passingAtoms, failingAtoms);
+        return ApprovalCopyResult.create(canCopy, passingAtoms.build(), failingAtoms.build());
       }
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log(
           "Unable to copy label because config is invalid. This should have been caught before.");
-      return false;
+      return ApprovalCopyResult.createForNonParseableCopyCondition();
     }
   }
 
@@ -321,8 +391,10 @@
     nonCopiedApprovalsForGivenPatchSet.forEach(
         psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
 
-    Table<String, Account.Id, PatchSetApproval> copiedApprovalsByUser = HashBasedTable.create();
-    ImmutableSet.Builder<PatchSetApproval> outdatedApprovalsBuilder = ImmutableSet.builder();
+    Table<String, Account.Id, Result.PatchSetApprovalData> copiedApprovalsByUser =
+        HashBasedTable.create();
+    ImmutableSet.Builder<Result.PatchSetApprovalData> outdatedApprovalsBuilder =
+        ImmutableSet.builder();
 
     ImmutableList<PatchSetApproval> priorApprovals =
         notes.load().getApprovals().all().get(priorPatchSet.getKey());
@@ -362,35 +434,55 @@
             priorPsa.key().patchSetId().changeId().get(),
             targetPsId.get(),
             projectName);
-        outdatedApprovalsBuilder.add(priorPsa);
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.createForMissingLabelType(priorPsa));
         continue;
       }
-      if (canCopy(
-          notes,
-          priorPsa.patchSetId(),
-          targetPatchSet,
-          priorPsa.accountId(),
-          labelType.get(),
-          priorPsa.value(),
-          changeKind,
-          isMerge,
-          rw,
-          repoConfig)) {
+      ApprovalCopyResult approvalCopyResult =
+          computeCopyResult(
+              notes,
+              priorPsa.patchSetId(),
+              targetPatchSet,
+              priorPsa.accountId(),
+              labelType.get(),
+              priorPsa.value(),
+              changeKind,
+              isMerge,
+              rw,
+              repoConfig);
+      if (approvalCopyResult.canCopy()) {
         if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+          PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
+
+          // Normalize the copied approval.
+          Optional<PatchSetApproval> copiedApprovalNormalized =
+              labelNormalizer.normalize(notes, copiedApproval);
+          logger.atFine().log(
+              "Copied approval %s has been normalized to %s",
+              copiedApproval,
+              copiedApprovalNormalized.map(PatchSetApproval::toString).orElse("n/a"));
+          if (!copiedApprovalNormalized.isPresent()) {
+            continue;
+          }
+
           copiedApprovalsByUser.put(
               priorPsa.label(),
               priorPsa.accountId(),
-              priorPsa.copyWithPatchSet(targetPatchSet.id()));
+              Result.PatchSetApprovalData.create(
+                  copiedApprovalNormalized.get(),
+                  approvalCopyResult.passingAtoms(),
+                  approvalCopyResult.failingAtoms()));
         }
       } else {
-        outdatedApprovalsBuilder.add(priorPsa);
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.create(
+                priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms()));
         continue;
       }
     }
 
-    ImmutableSet<PatchSetApproval> copiedApprovals =
-        labelNormalizer.normalize(notes, copiedApprovalsByUser.values()).getNormalized();
-    return Result.create(copiedApprovals, outdatedApprovalsBuilder.build());
+    return Result.create(
+        ImmutableSet.copyOf(copiedApprovalsByUser.values()), outdatedApprovalsBuilder.build());
   }
 
   private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
@@ -404,4 +496,72 @@
           e);
     }
   }
+
+  /**
+   * Evaluates a predicate of the copy condition and adds its passing and failing atoms to the given
+   * builders.
+   *
+   * @param predicate a predicate of the copy condition that should be evaluated
+   * @param approvalContext the approval context against which the predicate should be evaluated
+   * @param passingAtoms a builder to which passing atoms should be added
+   * @param failingAtoms a builder to which failing atoms should be added
+   */
+  private static void evaluateAtoms(
+      Predicate<ApprovalContext> predicate,
+      ApprovalContext approvalContext,
+      ImmutableSet.Builder<String> passingAtoms,
+      ImmutableSet.Builder<String> failingAtoms) {
+    if (predicate.isLeaf()) {
+      boolean isPassing = predicate.asMatchable().match(approvalContext);
+      (isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString());
+      return;
+    }
+    predicate
+        .getChildren()
+        .forEach(
+            childPredicate ->
+                evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms));
+  }
+
+  /** Result for checking if an approval can be copied to the next patch set. */
+  @AutoValue
+  abstract static class ApprovalCopyResult {
+    /** Whether the approval can be copied to the next patch set. */
+    abstract boolean canCopy();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> passingAtoms();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> failingAtoms();
+
+    private static ApprovalCopyResult create(
+        boolean canCopy, ImmutableSet<String> passingAtoms, ImmutableSet<String> failingAtoms) {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(canCopy, passingAtoms, failingAtoms);
+    }
+
+    private static ApprovalCopyResult createForMissingCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+
+    private static ApprovalCopyResult createForNonParseableCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index bd31356f..09820b1 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -25,6 +27,7 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -85,6 +88,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.StringTokenizer;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -399,12 +403,17 @@
       ChangeUpdate changeUpdate) {
     ApprovalCopier.Result approvalCopierResult =
         approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
-    approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
+    approvalCopierResult
+        .copiedApprovals()
+        .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval()));
 
     if (!notes.getChange().isWorkInProgress()) {
       // The attention set should not be updated when the change is work-in-progress.
       addAttentionSetUpdatesForOutdatedApprovals(
-          changeUpdate, approvalCopierResult.outdatedApprovals());
+          changeUpdate,
+          approvalCopierResult.outdatedApprovals().stream()
+              .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+              .collect(toImmutableSet()));
     }
 
     return approvalCopierResult;
@@ -511,31 +520,40 @@
    *       "is:FOO")}
    * </ul>
    *
-   * @param approvals the approvals that should be formatted
+   * @param approvalDatas the approvals that should be formatted, with approval meta data
    * @param labelTypes the label types
    * @return bullet list with the formatted approvals
    */
   private String formatApprovalListWithCopyCondition(
-      ImmutableSet<PatchSetApproval> approvals, LabelTypes labelTypes) {
+      ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+      LabelTypes labelTypes) {
     StringBuilder message = new StringBuilder();
 
     // sort approvals by label vote so that we list them in a deterministic order
-    ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
-        approvals.stream()
-            .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
+    ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> approvalsSortedByLabelVote =
+        approvalDatas.stream()
+            .sorted(
+                comparing(
+                    approvalData ->
+                        LabelVote.create(
+                                approvalData.patchSetApproval().label(),
+                                approvalData.patchSetApproval().value())
+                            .format()))
             .collect(toImmutableList());
 
-    ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
-        Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
+    ImmutableListMultimap<String, ApprovalCopier.Result.PatchSetApprovalData> approvalsByLabel =
+        Multimaps.index(
+            approvalsSortedByLabelVote, approvalData -> approvalData.patchSetApproval().label());
 
-    for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
-        approvalsByLabel.asMap().entrySet()) {
+    for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+        approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) {
       String label = approvalsByLabelEntry.getKey();
-      Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel =
+          approvalsByLabelEntry.getValue();
 
-      message.append("* ");
       if (!labelTypes.byLabel(label).isPresent()) {
         message
+            .append("* ")
             .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
             .append(" (label type is missing)\n");
         continue;
@@ -543,22 +561,65 @@
 
       LabelType labelType = labelTypes.byLabel(label).get();
       if (!labelType.getCopyCondition().isPresent()) {
-        message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
+        message
+            .append("* ")
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append("\n");
         continue;
       }
 
-      message
-          .append(
-              formatApprovalsWithCopyCondition(
-                  approvalsForSameLabel, labelType.getCopyCondition().get()))
-          .append("\n");
+      // Group the approvals that have the same label by the passing atoms. If approvals have the
+      // same label, but have different passing atoms, we need to list them in separate lines
+      // (because in each line we will highlight different passing atoms that matched). Approvals
+      // with the same label and the same passing atoms are formatted as a single line.
+      ImmutableListMultimap<ImmutableSet<String>, ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelByPassingAndFailingAtoms =
+              Multimaps.index(
+                  approvalsForSameLabel, ApprovalCopier.Result.PatchSetApprovalData::passingAtoms);
+
+      // Approvals with the same label that have the same passing atoms should have the same failing
+      // atoms (since the label is the same they have the same copy condition).
+      approvalsForSameLabelByPassingAndFailingAtoms
+          .asMap()
+          .values()
+          .forEach(
+              approvalsForSameLabelAndSamePassingAtoms ->
+                  checkThatPropertyIsTheSameForAllApprovals(
+                      approvalsForSameLabelAndSamePassingAtoms,
+                      "failing atoms",
+                      approvalData -> approvalData.failingAtoms()));
+
+      // The order in which we add lines for approvals with the same label but different passing
+      // atoms needs to be deterministic for tests. Just sort them by the string representation of
+      // the passing atoms.
+      for (Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelWithSamePassingAndFailingAtoms :
+              approvalsForSameLabelByPassingAndFailingAtoms.asMap().entrySet().stream()
+                  .sorted(
+                      comparing(
+                          (Map.Entry<
+                                      ImmutableSet<String>,
+                                      Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+                                  e) -> e.getKey().toString()))
+                  .map(Map.Entry::getValue)
+                  .collect(toImmutableList())) {
+        message
+            .append("* ")
+            .append(
+                formatApprovalsWithCopyCondition(
+                    approvalsForSameLabelWithSamePassingAndFailingAtoms,
+                    labelType.getCopyCondition().get()))
+            .append("\n");
+      }
     }
 
     return message.toString();
   }
 
   /**
-   * Formats the given approvals of the same label with the given copy condition.
+   * Formats the given approvals with the given copy condition.
+   *
+   * <p>The given approvals must have the same label and the same passing and failing atoms.
    *
    * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
    *
@@ -578,12 +639,29 @@
    *       "is:FOO")}
    * </ul>
    *
-   * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
+   * @param approvalsWithSameLabelAndSamePassingAndFailingAtoms the approvals that should be
+   *     formatted, must be for the same label
    * @param copyCondition the copy condition of the label
    * @return the formatted approvals
    */
   private String formatApprovalsWithCopyCondition(
-      Collection<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
+      Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+      String copyCondition) {
+    // Check that all given approvals have the same label and the same passing and failing atoms.
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "label",
+        approvalData -> approvalData.patchSetApproval().label());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "passing atoms",
+        approvalData -> approvalData.passingAtoms());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "failing atoms",
+        approvalData -> approvalData.failingAtoms());
+
     StringBuilder message = new StringBuilder();
 
     boolean containsUserInPredicate;
@@ -591,7 +669,8 @@
       containsUserInPredicate = containsUserInPredicate(copyCondition);
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log("Non-parsable query condition");
-      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
       message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
       return message.toString();
     }
@@ -618,26 +697,35 @@
 
       // sort the approvals by their approvers name-email so that the approvers always appear in a
       // deterministic order
-      ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
-          approvalsForSameLabel.stream()
-              .sorted(
-                  comparing(
-                          (PatchSetApproval psa) ->
-                              LabelVote.create(psa.label(), psa.value()).format())
-                      .thenComparing(
-                          psa ->
-                              accountCache
-                                  .getEvenIfMissing(psa.accountId())
-                                  .account()
-                                  .getNameEmail(anonymousCowardName)))
-              .collect(toImmutableList());
+      ImmutableList<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsSortedByLabelVoteAndApprover =
+              approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream()
+                  .sorted(
+                      comparing(
+                              (ApprovalCopier.Result.PatchSetApprovalData approvalData) ->
+                                  LabelVote.create(
+                                          approvalData.patchSetApproval().label(),
+                                          approvalData.patchSetApproval().value())
+                                      .format())
+                          .thenComparing(
+                              approvalData ->
+                                  accountCache
+                                      .getEvenIfMissing(approvalData.patchSetApproval().accountId())
+                                      .account()
+                                      .getNameEmail(anonymousCowardName)))
+                  .collect(toImmutableList());
 
       ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
           Multimaps.index(
                   approvalsSortedByLabelVoteAndApprover,
-                  psa -> LabelVote.create(psa.label(), psa.value()))
+                  approvalData ->
+                      LabelVote.create(
+                          approvalData.patchSetApproval().label(),
+                          approvalData.patchSetApproval().value()))
               .entries().stream()
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
+              .collect(
+                  toImmutableListMultimap(
+                      e -> e.getKey(), e -> e.getValue().patchSetApproval().accountId()));
       message.append(
           approversByLabelVote.asMap().entrySet().stream()
               .map(
@@ -647,12 +735,64 @@
               .collect(joining(", ")));
     } else {
       // copy condition doesn't contain a UserInPredicate
-      message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
     }
-    message.append(String.format(" (copy condition: \"%s\")", copyCondition));
+    ImmutableSet<String> passingAtoms =
+        !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty()
+            ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms()
+            : ImmutableSet.of();
+    message.append(
+        String.format(
+            " (copy condition: \"%s\")",
+            formatCopyConditionAsMarkdown(copyCondition, passingAtoms)));
     return message.toString();
   }
 
+  /** Checks that all given approvals have the same value for a given property. */
+  private void checkThatPropertyIsTheSameForAllApprovals(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      String propertyName,
+      Function<ApprovalCopier.Result.PatchSetApprovalData, ?> propertyExtractor) {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    Object propertyOfFirstEntry = propertyExtractor.apply(approvals.iterator().next());
+    approvals.forEach(
+        approvalData ->
+            checkState(
+                propertyExtractor.apply(approvalData).equals(propertyOfFirstEntry),
+                "property %s of approval %s does not match, expected value: %s",
+                propertyName,
+                approvalData,
+                propertyOfFirstEntry));
+  }
+
+  /**
+   * Formats the given copy condition as a Markdown string.
+   *
+   * <p>Passing atoms are formatted as bold.
+   *
+   * @param copyCondition the copy condition that should be formatted
+   * @param passingAtoms atoms of the copy conditions which are passing/matching
+   * @return the formatted copy condition as a Markdown string
+   */
+  private String formatCopyConditionAsMarkdown(
+      String copyCondition, ImmutableSet<String> passingAtoms) {
+    StringBuilder formattedCopyCondition = new StringBuilder();
+    StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true);
+    while (tokenizer.hasMoreTokens()) {
+      String token = tokenizer.nextToken();
+      if (passingAtoms.contains(token)) {
+        formattedCopyCondition.append("**" + token.replace("*", "\\*") + "**");
+      } else {
+        formattedCopyCondition.append(token);
+      }
+    }
+    return formattedCopyCondition.toString();
+  }
+
   private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
     // Use a request context to run checks as an internal user with expanded visibility. This is
     // so that the output of the copy condition does not depend on who is running the current
@@ -675,8 +815,9 @@
    * @return the given approvals as a comma-separated list of label votes
    */
   private String formatApprovalsAsLabelVotesList(
-      Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> sortedApprovalsForSameLabel) {
     return sortedApprovalsForSameLabel.stream()
+        .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
         .map(psa -> LabelVote.create(psa.label(), psa.value()))
         .distinct()
         .map(LabelVote::format)
diff --git a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
index aa8a958..3cad7ce 100644
--- a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
@@ -16,9 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.NamedOptionDef;
 import org.kohsuke.args4j.OptionDef;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Parameters;
@@ -37,7 +39,14 @@
   @Override
   public int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    setter.addValue(ObjectId.fromString(n));
+    try {
+      setter.addValue(ObjectId.fromString(n));
+    } catch (InvalidObjectIdException e) {
+      throw new CmdLineException(
+          owner,
+          String.format("expected SHA1 for option %s: %s", ((NamedOptionDef) option).name(), n),
+          e);
+    }
     return 1;
   }
 
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index d575324..2883ef8 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -32,6 +32,7 @@
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -653,6 +654,9 @@
   }
 
   private ImmutableList<InternalReviewerInput> getReviewerInputs() {
+    if (projectState.is(BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS)) {
+      return reviewerInputs;
+    }
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index b1f9726..194a4f0 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -72,8 +72,8 @@
     }
 
     List<ChangeData> cds =
-        InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, changeData.project(), groups);
+        InternalChangeQuery.byBranchGroups(
+            queryProvider, indexConfig, changeData.change().getDest(), groups);
     if (cds.isEmpty()) {
       return Collections.emptyList();
     }
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 79e2054..b1fcf48 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -21,6 +21,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -43,6 +44,16 @@
  * what labels are defined for the project. The label definition can change between the time a vote
  * is originally made and a later point, for example when a change is submitted. This class
  * normalizes old votes against current project configuration.
+ *
+ * <p>Normalizing a vote means making it compliant with the current label definition:
+ *
+ * <ul>
+ *   <li>If the voting value is greater than the max allowed value according to the label
+ *       definition, the voting value is changed to the max allowed value.
+ *   <li>If the voting value is lower than the min allowed value according to the label definition,
+ *       the voting value is changed to the min allowed value.
+ *   <li>If the label definition for a vote is missing, the vote is deleted.
+ * </ul>
  */
 @Singleton
 public class LabelNormalizer {
@@ -121,6 +132,20 @@
     return Result.create(unchanged, updated, deleted);
   }
 
+  /**
+   * Returns a copy of the given approval normalized to the defined ranges for the label type. If
+   * the approval is for an unknown label {@link Optional#empty()} is returned
+   *
+   * @param notes change notes containing the given approval
+   * @param approval approval that should be normalized
+   */
+  public Optional<PatchSetApproval> normalize(ChangeNotes notes, PatchSetApproval approval) {
+    Result result = normalize(notes, ImmutableSet.of(approval));
+    return Optional.ofNullable(
+        Iterables.getFirst(
+            result.unchanged(), Iterables.getFirst(result.updated(), /* defaultValue= */ null)));
+  }
+
   private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
     PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index f7bec1c0..1fe67af 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -368,7 +369,9 @@
               ctx,
               patchSet,
               mailMessage,
-              approvalCopierResult.outdatedApprovals(),
+              approvalCopierResult.outdatedApprovals().stream()
+                  .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                  .collect(toImmutableSet()),
               oldReviewers.byState(REVIEWER),
               oldReviewers.byState(CC),
               changeKind,
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 60e30bc..cd7e29a 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -295,8 +295,8 @@
     // Find changes in the same related group as this patch set, having a patch
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
-        InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, change.getProject(), currentPs.groups())) {
+        InternalChangeQuery.byBranchGroups(
+            queryProvider, indexConfig, change.getDest(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ps.commitId());
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 6e5cfff..0e17342 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -510,7 +510,9 @@
             ctx,
             newPatchSet,
             mailMessage,
-            approvalCopierResult.outdatedApprovals(),
+            approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                .collect(toImmutableSet()),
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
@@ -595,6 +597,7 @@
     return Optional.of(
         "The following approvals got outdated and were removed:\n"
             + approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
                 .map(
                     outdatedApproval ->
                         String.format(
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 7117d66..bd93c6d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -556,6 +556,7 @@
 
   public static final IndexedField<ChangeData, String> IS_PURE_REVERT_FIELD =
       IndexedField.<ChangeData>stringBuilder("IsPureRevert")
+          .size(1)
           .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
 
   public static final IndexedField<ChangeData, String>.SearchSpec IS_PURE_REVERT_SPEC =
@@ -567,6 +568,7 @@
    */
   public static final IndexedField<ChangeData, String> IS_SUBMITTABLE_FIELD =
       IndexedField.<ChangeData>stringBuilder("IsSubmittable")
+          .size(1)
           .build(
               cd ->
                   // All submit requirements should be fulfilled
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index ae9828a..9ccbf90 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -71,6 +71,11 @@
           .put(
               BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT,
               new Mapper(i -> i.workInProgressByDefault, (i, v) -> i.workInProgressByDefault = v))
+          .put(
+              BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+              new Mapper(
+                  i -> i.skipAddingAuthorAndCommitterAsReviewers,
+                  (i, v) -> i.skipAddingAuthorAndCommitterAsReviewers = v))
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index 196873f..8da0510 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -49,7 +49,7 @@
     newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
+    submitType = SubmitType.INHERIT;
     rejectEmptyCommit = InheritableBoolean.INHERIT;
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index a964ee1..fc1256e 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -1143,9 +1143,11 @@
         error(
             String.format(
                 "Invalid %s for label \"%s\". Valid names are: %s",
-                KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
+                KEY_FUNCTION,
+                name,
+                Joiner.on(", ").join(LabelFunction.ALL_NON_DEPRECATED.keySet())));
       }
-      label.setFunction(function.orElse(null));
+      function.ifPresent(label::setFunction);
       label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
 
       if (!values.isEmpty()) {
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index e86ad41..07f7ba5 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import java.io.IOException;
 import java.util.Collections;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RevisionSyntaxException;
@@ -49,6 +50,9 @@
     } catch (RevisionSyntaxException e) {
       throw new UnprocessableEntityException(
           String.format("base revision \"%s\" is invalid", baseRevision), e);
+    } catch (AmbiguousObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" is ambiguous", baseRevision), e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 433abe6..fa75542 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.query.account;
 
+import static com.google.gerrit.server.index.account.AccountField.USERNAME_SPEC;
+
+import com.google.common.base.Ascii;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
@@ -59,7 +62,9 @@
         }
       }
     }
-    preds.add(username(query));
+    if (schema.hasField(USERNAME_SPEC)) {
+      preds.add(username(query));
+    }
     // Adapt the capacity of the "predicates" list when adding more default
     // predicates.
     return Predicate.or(preds);
@@ -76,14 +81,14 @@
 
   public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
     return new AccountPredicate(
-        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, Ascii.toLowerCase(email));
   }
 
   public static Predicate<AccountState> preferredEmail(String email) {
     return new AccountPredicate(
         AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
         AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
-        email.toLowerCase());
+        Ascii.toLowerCase(email));
   }
 
   public static Predicate<AccountState> preferredEmailExact(String email) {
@@ -95,14 +100,14 @@
 
   public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, Ascii.toLowerCase(name));
   }
 
   public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
         AccountQueryBuilder.FIELD_NAME,
-        name.toLowerCase());
+        Ascii.toLowerCase(name));
   }
 
   public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
@@ -123,7 +128,7 @@
 
   public static Predicate<AccountState> username(String username) {
     return new AccountPredicate(
-        AccountField.USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+        USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, Ascii.toLowerCase(username));
   }
 
   public static Predicate<AccountState> watchedProject(Project.NameKey project) {
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 99c1ca1..ccd645b 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -271,21 +271,21 @@
     return query(ChangePredicates.submissionId(cs));
   }
 
-  private static Predicate<ChangeData> byProjectGroupsPredicate(
-      IndexConfig indexConfig, Project.NameKey project, Collection<String> groups) {
-    int n = indexConfig.maxTerms() - 1;
+  private static Predicate<ChangeData> byBranchGroupsPredicate(
+      IndexConfig indexConfig, BranchNameKey branchAndProject, Collection<String> groups) {
+    int n = indexConfig.maxTerms() - 2;
     checkArgument(groups.size() <= n, "cannot exceed %s groups", n);
     List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
     for (String g : groups) {
       groupPredicates.add(new GroupPredicate(g));
     }
-    return and(project(project), or(groupPredicates));
+    return and(project(branchAndProject.project()), ref(branchAndProject), or(groupPredicates));
   }
 
-  public static ImmutableList<ChangeData> byProjectGroups(
+  public static ImmutableList<ChangeData> byBranchGroups(
       Provider<InternalChangeQuery> queryProvider,
       IndexConfig indexConfig,
-      Project.NameKey project,
+      BranchNameKey branchAndProject,
       Collection<String> groups) {
     // These queries may be complex along multiple dimensions:
     //  * Many groups per change, if there are very many patch sets. This requires partitioning the
@@ -296,16 +296,17 @@
     // InternalChangeQuery is single-use.
 
     Supplier<InternalChangeQuery> querySupplier = () -> queryProvider.get().enforceVisibility(true);
-    int batchSize = indexConfig.maxTerms() - 1;
+    int batchSize = indexConfig.maxTerms() - 2;
     if (groups.size() <= batchSize) {
       return queryExhaustively(
-          querySupplier, byProjectGroupsPredicate(indexConfig, project, groups));
+          querySupplier, byBranchGroupsPredicate(indexConfig, branchAndProject, groups));
     }
     Set<Change.Id> seen = new HashSet<>();
     ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
-          queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
+          queryExhaustively(
+              querySupplier, byBranchGroupsPredicate(indexConfig, branchAndProject, part))) {
         if (!seen.add(cd.getId())) {
           result.add(cd);
         }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 7a6ac0d..9a6b03e 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -156,7 +156,6 @@
 
   private final BatchUpdate.Factory updateFactory;
   private final PostReviewOp.Factory postReviewOpFactory;
-  private final PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
@@ -180,7 +179,6 @@
   PostReview(
       BatchUpdate.Factory updateFactory,
       PostReviewOp.Factory postReviewOpFactory,
-      PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
@@ -200,7 +198,6 @@
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
     this.postReviewOpFactory = postReviewOpFactory;
-    this.postReviewCopyApprovalsOpFactory = postReviewCopyApprovalsOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
@@ -384,9 +381,6 @@
       PostReviewOp postReviewOp =
           postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
       bu.addOp(revision.getChange().getId(), postReviewOp);
-      bu.addOp(
-          revision.getChange().getId(),
-          postReviewCopyApprovalsOpFactory.create(revision.getPatchSet().id()));
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
deleted file mode 100644
index 88d2d7b..0000000
--- a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
+++ /dev/null
@@ -1,236 +0,0 @@
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.restapi.change;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.auto.factory.AutoFactory;
-import com.google.auto.factory.Provided;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableTable;
-import com.google.common.collect.Table.Cell;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.approval.ApprovalCopier;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import java.io.IOException;
-import java.util.Optional;
-
-/**
- * Batch update operation that copy approvals that have been newly applied on outdated patch sets to
- * the follow-up patch sets if they are copyable and no non-copied approvals prevent the copying.
- *
- * <p>Must be invoked after the batch update operation which applied new approvals on outdated patch
- * sets (e.g. after {@link PostReviewOp}.
- */
-@AutoFactory
-public class PostReviewCopyApprovalsOp implements BatchUpdateOp {
-  private final ApprovalCopier approvalCopier;
-  private final PatchSetUtil patchSetUtil;
-  private final PatchSet.Id patchSetId;
-
-  private ChangeContext ctx;
-  private ImmutableList<PatchSet.Id> followUpPatchSets;
-
-  PostReviewCopyApprovalsOp(
-      @Provided ApprovalCopier approvalCopier,
-      @Provided PatchSetUtil patchSetUtil,
-      PatchSet.Id patchSetId) {
-    this.approvalCopier = approvalCopier;
-    this.patchSetUtil = patchSetUtil;
-    this.patchSetId = patchSetId;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) throws IOException {
-    if (ctx.getNotes().getCurrentPatchSet().id().equals(patchSetId)) {
-      // the updated patch set is the current patch, there a no follow-up patch set to which new
-      // approvals could be copied
-      return false;
-    }
-
-    init(ctx);
-
-    boolean dirty = false;
-    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
-        ctx.getUpdate(patchSetId).getApprovals();
-    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
-      String label = cell.getRowKey();
-      Account.Id approverId = cell.getColumnKey();
-      PatchSetApproval.Key psaKey =
-          PatchSetApproval.key(patchSetId, approverId, LabelId.create(label));
-
-      if (isRemoval(cell)) {
-        if (removeCopies(psaKey)) {
-          dirty = true;
-        }
-        continue;
-      }
-
-      PatchSet patchSet = patchSetUtil.get(ctx.getNotes(), patchSetId);
-      PatchSetApproval psaOrig = cell.getValue().get();
-
-      // Target patch sets to which the approval is copyable.
-      ImmutableList<PatchSet.Id> targetPatchSets =
-          approvalCopier.forApproval(
-              ctx.getNotes(),
-              patchSet,
-              psaKey.accountId(),
-              psaKey.labelId().get(),
-              psaOrig.value());
-
-      // Iterate over all follow-up patch sets, in patch set order.
-      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
-        if (hasOverrideOf(followUpPatchSetId, psaKey)) {
-          // a non-copied approval exists that overrides any copied approval
-          // -> do not copy the approval to this patch set nor to any follow-up patch sets
-          break;
-        }
-
-        if (targetPatchSets.contains(followUpPatchSetId)) {
-          // The approval is copyable to the new patch set.
-
-          if (hasCopyOfWithValue(followUpPatchSetId, psaKey, psaOrig.value())) {
-            // a copy approval with the exact value already exists
-            continue;
-          }
-
-          // add/update the copied approval on the target patch set
-          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
-          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
-          dirty = true;
-        } else {
-          // The approval is not copyable to the new patch set.
-
-          if (hasCopyOf(followUpPatchSetId, psaKey)) {
-            // a copy approval exists and should be removed
-            removeCopy(followUpPatchSetId, psaKey);
-            dirty = true;
-          }
-        }
-      }
-    }
-
-    return dirty;
-  }
-
-  private void init(ChangeContext ctx) {
-    this.ctx = ctx;
-
-    // compute follow-up patch sets (sorted by patch set ID)
-    this.followUpPatchSets =
-        ctx.getNotes().getPatchSets().keySet().stream()
-            .filter(psId -> psId.get() > patchSetId.get())
-            .collect(toImmutableList());
-  }
-
-  /**
-   * Whether the given cell entry from the approval table represents the removal of an approval.
-   *
-   * @param cell cell entry from the approval table
-   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
-   *     otherwise {@code false}
-   */
-  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
-    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
-  }
-
-  /**
-   * Removes copies of the given approval from all follow-up patch sets.
-   *
-   * @param psaKey the key of the patch set approval for which copies should be removed from all
-   *     follow-up patch sets
-   * @return whether any copy approval has been removed
-   */
-  private boolean removeCopies(PatchSetApproval.Key psaKey) {
-    boolean dirty = false;
-    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
-      if (hasCopyOf(followUpPatchSet, psaKey)) {
-        removeCopy(followUpPatchSet, psaKey);
-      } else {
-        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
-        // patch sets (if the further follow-up patch sets have copies they are copies of a
-        // non-copied approval on this follow-up patch set and hence those should not be removed).
-        break;
-      }
-    }
-    return dirty;
-  }
-
-  /**
-   * Removes the copy approval with the given key from the given patch set.
-   *
-   * @param patchSet patch set from which the copy approval with the given key should be removed
-   * @param psaKey the key of the patch set approval for which copies should be removed from the
-   *     given patch set
-   */
-  private void removeCopy(PatchSet.Id patchSet, PatchSetApproval.Key psaKey) {
-    ctx.getUpdate(patchSet)
-        .removeCopiedApprovalFor(
-            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
-                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
-                : null,
-            psaKey.accountId(),
-            psaKey.labelId().get());
-  }
-
-  /**
-   * Whether the given patch set has a copy approval with the given key.
-   *
-   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
-   *     approval with the given key
-   * @param psaKey the key of the patch set approval
-   */
-  private boolean hasCopyOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
-    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
-        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
-  }
-
-  /**
-   * Whether the given patch set has a copy approval with the given key and value.
-   *
-   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
-   *     approval with the given key and value
-   * @param psaKey the key of the patch set approval
-   */
-  private boolean hasCopyOfWithValue(
-      PatchSet.Id patchSetId, PatchSetApproval.Key psaKey, short value) {
-    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
-        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey) && psa.value() == value);
-  }
-
-  /**
-   * Whether the given patch set has a normal approval with the given key that overrides copy
-   * approvals with that key.
-   *
-   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
-   *     approval with the given key that overrides copy approvals with that key
-   * @param psaKey the key of the patch set approval
-   */
-  private boolean hasOverrideOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
-    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
-        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
-  }
-
-  private boolean areAccountAndLabelTheSame(
-      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
-    return psaKey1.accountId().equals(psaKey2.accountId())
-        && psaKey1.labelId().equals(psaKey2.labelId());
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 5ff0968..8a92046 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -18,15 +18,22 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SortedSetMultimap;
 import com.google.common.collect.Streams;
+import com.google.common.collect.Table.Cell;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
@@ -55,6 +62,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -93,9 +101,85 @@
     PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
   }
 
+  /**
+   * Update of a copied label that has been performed on a follow-up patch set after a vote has been
+   * applied on an outdated patch set (follow-up patch sets = all patch sets that are newer than the
+   * outdated patch set on which the user voted).
+   */
+  @AutoValue
+  abstract static class CopiedLabelUpdate {
+    /**
+     * Type of the update that has been performed for a copied vote on a follow-up patch set.
+     *
+     * <p>Whether the copied vote has been added
+     *
+     * <ul>
+     *   <li>added to
+     *   <li>updated on
+     *   <li>removed from
+     * </ul>
+     *
+     * a follow-up patch set.
+     */
+    enum Type {
+      /** A copied vote was added. No copied vote existed for this label yet. */
+      ADDED,
+
+      /** An existing copied vote has been updated. */
+      UPDATED,
+
+      /** An existing copied vote has been removed. */
+      REMOVED;
+    }
+
+    /** The ID of the (follow-up) patch set on which the copied label update has been performed. */
+    abstract PatchSet.Id patchSetId();
+
+    /**
+     * The old copied label vote that has been updated or that has been removed.
+     *
+     * <p>Not set if {@link #type()} is {@link Type#ADDED}.
+     */
+    abstract Optional<LabelVote> oldLabelVote();
+
+    /**
+     * The type of the update that has been performed for the copied vote on the (follow-up) patch
+     * set.
+     */
+    abstract Type type();
+
+    /** Returns a string with the patch set number and if present the old label vote. */
+    private String formatPatchSetWithOldLabelVote() {
+      StringBuilder b = new StringBuilder();
+      b.append(patchSetId().get());
+      if (oldLabelVote().isPresent()) {
+        b.append(" (was ").append(oldLabelVote().get().format()).append(")");
+      }
+      return b.toString();
+    }
+
+    private static CopiedLabelUpdate added(PatchSet.Id patchSetId) {
+      return create(patchSetId, Optional.empty(), Type.ADDED);
+    }
+
+    private static CopiedLabelUpdate updated(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+      return create(patchSetId, Optional.of(oldLabelVote), Type.UPDATED);
+    }
+
+    private static CopiedLabelUpdate removed(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+      return create(patchSetId, Optional.of(oldLabelVote), Type.REMOVED);
+    }
+
+    private static CopiedLabelUpdate create(
+        PatchSet.Id patchSetId, Optional<LabelVote> oldLabelVote, Type type) {
+      return new AutoValue_PostReviewOp_CopiedLabelUpdate(patchSetId, oldLabelVote, type);
+    }
+  }
+
   @VisibleForTesting
   public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
 
+  private final ApprovalCopier approvalCopier;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
@@ -117,12 +201,15 @@
   private String mailMessage;
   private List<Comment> comments = new ArrayList<>();
   private List<LabelVote> labelDelta = new ArrayList<>();
+  private SortedSetMultimap<LabelVote, CopiedLabelUpdate> labelUpdatesOnFollowUpPatchSets =
+      MultimapBuilder.hashKeys().treeSetValues(comparing(CopiedLabelUpdate::patchSetId)).build();
   private Map<String, Short> approvals = new HashMap<>();
   private Map<String, Short> oldApprovals = new HashMap<>();
 
   @Inject
   PostReviewOp(
       @GerritServerConfig Config gerritConfig,
+      ApprovalCopier approvalCopier,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
@@ -135,6 +222,7 @@
       @Assisted ProjectState projectState,
       @Assisted PatchSet.Id psId,
       @Assisted ReviewInput in) {
+    this.approvalCopier = approvalCopier;
     this.approvalsUtil = approvalsUtil;
     this.publishCommentUtil = publishCommentUtil;
     this.psUtil = psUtil;
@@ -171,6 +259,9 @@
     try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
       dirty |= updateLabels(projectState, ctx);
     }
+    try (TraceContext.TraceTimer ignored = newTimer("updateCopiedApprovals")) {
+      dirty |= updateCopiedApprovalsOnFollowUpPatchSets(ctx);
+    }
     try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
       dirty |= insertMessage(ctx);
     }
@@ -705,12 +796,226 @@
     return current;
   }
 
+  /**
+   * Copies approvals that have been newly applied on outdated patch sets to the follow-up patch
+   * sets if they are copyable and no non-copied approvals prevent the copying.
+   *
+   * <p>Must be invoked after the new approvals on outdated patch sets have been applied (e.g. after
+   * {@link #updateLabels(ProjectState, ChangeContext)}.
+   *
+   * @param ctx the change context
+   * @return {@code true} if an update was done, otherwise {@code false}
+   */
+  private boolean updateCopiedApprovalsOnFollowUpPatchSets(ChangeContext ctx) throws IOException {
+    if (ctx.getNotes().getCurrentPatchSet().id().equals(psId)) {
+      // the updated patch set is the current patch, there a no follow-up patch set to which new
+      // approvals could be copied
+      return false;
+    }
+
+    // compute follow-up patch sets (sorted by patch set ID)
+    ImmutableList<PatchSet.Id> followUpPatchSets =
+        ctx.getNotes().getPatchSets().keySet().stream()
+            .filter(patchSetId -> patchSetId.get() > psId.get())
+            .collect(toImmutableList());
+
+    boolean dirty = false;
+    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
+        ctx.getUpdate(psId).getApprovals();
+    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
+      PatchSetApproval psaOrig = cell.getValue().get();
+
+      if (isRemoval(cell)) {
+        if (removeCopies(ctx, followUpPatchSets, psaOrig)) {
+          dirty = true;
+        }
+        continue;
+      }
+
+      PatchSet patchSet = psUtil.get(ctx.getNotes(), psId);
+
+      // Target patch sets to which the approval is copyable.
+      ImmutableList<PatchSet.Id> targetPatchSets =
+          approvalCopier.forApproval(
+              ctx.getNotes(), patchSet, psaOrig.accountId(), psaOrig.label(), psaOrig.value());
+
+      // Iterate over all follow-up patch sets, in patch set order.
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        if (hasOverrideOf(ctx, followUpPatchSetId, psaOrig.key())) {
+          // a non-copied approval exists that overrides any copied approval
+          // -> do not copy the approval to this patch set nor to any follow-up patch sets
+          break;
+        }
+
+        if (targetPatchSets.contains(followUpPatchSetId)) {
+          // The approval is copyable to the new patch set.
+
+          if (hasCopyOfWithValue(ctx, followUpPatchSetId, psaOrig)) {
+            // a copy approval with the exact value already exists
+            continue;
+          }
+
+          // add/update the copied approval on the target patch set
+          Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
+          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
+          labelUpdatesOnFollowUpPatchSets.put(
+              LabelVote.createFrom(psaOrig),
+              copiedPsa.isPresent()
+                  ? CopiedLabelUpdate.updated(
+                      followUpPatchSetId, LabelVote.createFrom(copiedPsa.get()))
+                  : CopiedLabelUpdate.added(followUpPatchSetId));
+          dirty = true;
+        } else {
+          // The approval is not copyable to the new patch set.
+          Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+          if (copiedPsa.isPresent()) {
+            // a copy approval exists and should be removed
+            removeCopy(ctx, psaOrig, copiedPsa.get());
+            dirty = true;
+          }
+        }
+      }
+    }
+
+    return dirty;
+  }
+
+  /**
+   * Whether the given cell entry from the approval table represents the removal of an approval.
+   *
+   * @param cell cell entry from the approval table
+   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
+   *     otherwise {@code false}
+   */
+  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
+    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
+  }
+
+  /**
+   * Removes copies of the given approval from all follow-up patch sets.
+   *
+   * @param ctx the change context
+   * @param followUpPatchSets the follow-up patch sets of the patch set on which the review is
+   *     posted
+   * @param psaOrig the original patch set approval for which copies should be removed from all
+   *     follow-up patch sets
+   * @return whether any copy approval has been removed
+   */
+  private boolean removeCopies(
+      ChangeContext ctx, ImmutableList<PatchSet.Id> followUpPatchSets, PatchSetApproval psaOrig) {
+    boolean dirty = false;
+    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
+      Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSet, psaOrig.key());
+      if (copiedPsa.isPresent()) {
+        removeCopy(ctx, psaOrig, copiedPsa.get());
+      } else {
+        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
+        // patch sets (if the further follow-up patch sets have copies they are copies of a
+        // non-copied approval on this follow-up patch set and hence those should not be removed).
+        break;
+      }
+    }
+    return dirty;
+  }
+
+  /**
+   * Removes the copy approval with the given key from the given patch set.
+   *
+   * @param ctx the change context
+   * @param psaOrig the original patch set approval for which copies should be removed from the
+   *     given patch set
+   * @param copiedPsa the copied patch set approval that should be removed
+   */
+  private void removeCopy(ChangeContext ctx, PatchSetApproval psaOrig, PatchSetApproval copiedPsa) {
+    ctx.getUpdate(copiedPsa.patchSetId())
+        .removeCopiedApprovalFor(
+            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
+                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
+                : null,
+            copiedPsa.accountId(),
+            copiedPsa.labelId().get());
+    labelUpdatesOnFollowUpPatchSets.put(
+        LabelVote.createFrom(psaOrig),
+        CopiedLabelUpdate.removed(copiedPsa.patchSetId(), LabelVote.createFrom(copiedPsa)));
+  }
+
+  /**
+   * Retrieves the copy of the given approval from the given patch set if it exists.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch from which it the copied approval should be returned
+   * @param psaKey the key of the patch set approval for which the copied approval should be
+   *     returned
+   * @return the copy of the given approval from the given patch set if it exists
+   */
+  private Optional<PatchSetApproval> getCopyOf(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .filter(psa -> areAccountAndLabelTheSame(psa.key(), psaKey))
+        .findAny();
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key and value.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key and value
+   * @param psaOrig the original patch set approval
+   */
+  private boolean hasCopyOfWithValue(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval psaOrig) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(
+            psa ->
+                areAccountAndLabelTheSame(psa.key(), psaOrig.key())
+                    && psa.value() == psaOrig.value());
+  }
+
+  /**
+   * Whether the given patch set has a normal approval with the given key that overrides copy
+   * approvals with that key.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
+   *     approval with the given key that overrides copy approvals with that key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasOverrideOf(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  private boolean areAccountAndLabelTheSame(
+      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
+    return psaKey1.accountId().equals(psaKey2.accountId())
+        && psaKey1.labelId().equals(psaKey2.labelId());
+  }
+
   private boolean insertMessage(ChangeContext ctx) {
     String msg = Strings.nullToEmpty(in.message).trim();
 
     StringBuilder buf = new StringBuilder();
-    for (LabelVote d : labelDelta) {
-      buf.append(" ").append(d.format());
+    for (String formattedLabelVote :
+        labelDelta.stream().map(LabelVote::format).sorted().collect(toImmutableList())) {
+      buf.append(" ").append(formattedLabelVote);
+    }
+    if (!labelUpdatesOnFollowUpPatchSets.isEmpty()) {
+      buf.append("\n\nCopied votes on follow-up patch sets have been updated:");
+      for (Map.Entry<LabelVote, Collection<CopiedLabelUpdate>> e :
+          labelUpdatesOnFollowUpPatchSets.asMap().entrySet().stream()
+              .sorted(Map.Entry.comparingByKey(comparing(LabelVote::label)))
+              .collect(toImmutableList())) {
+        Optional<String> copyCondition =
+            projectState
+                .getLabelTypes(ctx.getNotes())
+                .byLabel(e.getKey().label())
+                .map(LabelType::getCopyCondition)
+                .map(Optional::get);
+        buf.append(formatVotesCopiedToFollowUpPatchSets(e.getKey(), e.getValue(), copyCondition));
+      }
     }
     if (comments.size() == 1) {
       buf.append("\n\n(1 comment)");
@@ -748,6 +1053,88 @@
     return true;
   }
 
+  /**
+   * Given a label vote that has been applied on an outdated patch set, this method formats the
+   * updates to the copied labels on the follow-up patch sets that have been performed for that
+   * label vote.
+   *
+   * <p>If label votes have been copied to follow-up patch sets the formatted message is
+   * "<label-vote> has been copied to patch sets: 3, 4 (copy condition: "<copy-condition>").".
+   *
+   * <p>If existing copied votes on follow-up patch sets have been updated, the old copied votes are
+   * included into the message: "<label-vote> has been copied to patch sets: 3 (was
+   * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+   *
+   * <p>If existing copied votes on follow-up patch sets have been removed (because the new vote is
+   * not copyable) the message is: "Copied <label> vote has been removed from patch set 3 (was
+   * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+   *
+   * <p>If copied votes have been both added/updated and removed, 2 messages are returned.
+   *
+   * <p>Each returned message is formatted as a list item (prefixed with '* ').
+   *
+   * <p>Passing atoms in copy conditions are not highlighted. This is because the passing atoms can
+   * be different for different follow-up patch sets (e.g. 'changekind:TRIVIAL_REBASE OR
+   * changekind:NO_CODE_CHANGE' can have 'changekind:TRIVIAL_REBASE' passing for one follow-up patch
+   * set and 'changekind:NO_CODE_CHANGE' passing for another follow-up patch set). Including the
+   * copy condition once per follow-up patch set with differently highlighted passing atoms would
+   * make the message unreadable. Hence we don't highlight passing atoms here.
+   *
+   * @param labelVote the label vote that has been applied on an outdated patch set
+   * @param followUpPatchSetUpdates updates to copied votes on follow-up patch sets that have been
+   *     done by copying the label vote on the outdated patch set to follow-up patch sets
+   * @param copyCondition the copy condition of the label for which a vote was applied on an
+   *     outdated patch set
+   * @return formatted string to be included into a change message
+   */
+  private String formatVotesCopiedToFollowUpPatchSets(
+      LabelVote labelVote,
+      Collection<CopiedLabelUpdate> followUpPatchSetUpdates,
+      Optional<String> copyCondition) {
+    StringBuilder b = new StringBuilder();
+
+    // Add line for added/updated copied approvals.
+    ImmutableList<CopiedLabelUpdate> additionsAndUpdates =
+        followUpPatchSetUpdates.stream()
+            .filter(
+                copiedLabelUpdate ->
+                    copiedLabelUpdate.type() == CopiedLabelUpdate.Type.ADDED
+                        || copiedLabelUpdate.type() == CopiedLabelUpdate.Type.UPDATED)
+            .collect(toImmutableList());
+    if (!additionsAndUpdates.isEmpty()) {
+      b.append("\n* ");
+      b.append(labelVote.format());
+      b.append(" has been copied to patch set ");
+      b.append(
+          additionsAndUpdates.stream()
+              .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+              .collect(joining(", ")));
+      copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+      b.append(".");
+    }
+
+    // Add line for removed copied approvals.
+    ImmutableList<CopiedLabelUpdate> removals =
+        followUpPatchSetUpdates.stream()
+            .filter(copiedLabelUpdate -> copiedLabelUpdate.type() == CopiedLabelUpdate.Type.REMOVED)
+            .collect(toImmutableList());
+    if (!removals.isEmpty()) {
+      b.append("\n* Copied ");
+      b.append(labelVote.label());
+      b.append(" vote has been removed from patch set ");
+      b.append(
+          removals.stream()
+              .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+              .collect(joining(", ")));
+      b.append(" since the new ");
+      b.append(labelVote.value() != 0 ? labelVote.format() : labelVote.formatWithEquals());
+      b.append(" vote is not copyable");
+      copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+      b.append(".");
+    }
+    return b.toString();
+  }
+
   private void addLabelDelta(String name, short value) {
     labelDelta.add(LabelVote.create(name, value));
   }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 23d60fe..6957275 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
+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.restapi.BadRequestException;
@@ -39,6 +42,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -80,6 +84,8 @@
       throws Exception {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
+    validateInput(input);
+
     ProjectConfig config;
 
     List<AccessSection> removals =
@@ -137,4 +143,65 @@
 
     return Response.ok(getAccess.apply(rsrc.getNameKey()));
   }
+
+  private static void validateInput(ProjectAccessInput input) throws BadRequestException {
+    if (input.add != null) {
+      for (Map.Entry<String, AccessSectionInfo> accessSectionEntry : input.add.entrySet()) {
+        validateAccessSection(accessSectionEntry.getKey(), accessSectionEntry.getValue());
+      }
+    }
+  }
+
+  private static void validateAccessSection(String ref, AccessSectionInfo accessSectionInfo)
+      throws BadRequestException {
+    if (accessSectionInfo != null) {
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          accessSectionInfo.permissions.entrySet()) {
+        validatePermission(ref, permissionEntry.getKey(), permissionEntry.getValue());
+      }
+    }
+  }
+
+  private static void validatePermission(
+      String ref, String permission, PermissionInfo permissionInfo) throws BadRequestException {
+    if (permissionInfo != null) {
+      for (Map.Entry<String, PermissionRuleInfo> permissionRuleEntry :
+          permissionInfo.rules.entrySet()) {
+        validatePermissionRule(
+            ref, permission, permissionRuleEntry.getKey(), permissionRuleEntry.getValue());
+      }
+    }
+  }
+
+  private static void validatePermissionRule(
+      String ref, String permission, String groupId, PermissionRuleInfo permissionRuleInfo)
+      throws BadRequestException {
+    if (permissionRuleInfo != null) {
+      if (permissionRuleInfo.min != null || permissionRuleInfo.max != null) {
+        if (permissionRuleInfo.min == null) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " ..%d (min is required if max is set)",
+                  permission, groupId, ref, permissionRuleInfo.max));
+        }
+
+        if (permissionRuleInfo.max == null) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " %d.. (max is required if min is set)",
+                  permission, groupId, ref, permissionRuleInfo.min));
+        }
+
+        if (permissionRuleInfo.min > permissionRuleInfo.max) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " %d..%d (min must be <= max)",
+                  permission, groupId, ref, permissionRuleInfo.min, permissionRuleInfo.max));
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index 038fe2c..fbcf3ce 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
 
 /** A single vote on a label, consisting of a label name and a value. */
 @AutoValue
@@ -68,6 +69,10 @@
     return new AutoValue_LabelVote(LabelType.checkNameInternal(label), value);
   }
 
+  public static LabelVote createFrom(PatchSetApproval psa) {
+    return create(psa.label(), psa.value());
+  }
+
   public abstract String label();
 
   public abstract short value();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
index f8cf5fd..2b1bef0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -45,12 +45,29 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import org.junit.Test;
 
+/**
+ * Tests to verify that copied/outdated approvals are included into the change message that is
+ * posted on patch set creation. Includes verifying that the copied/outdated approvals in the change
+ * message are correctly formatted.
+ *
+ * <p>Some of the tests only verify the correct formatting of the copied/outdated approvals in the
+ * change message that is done by {@link
+ * ApprovalsUtil#formatApprovalCopierResult(com.google.gerrit.server.approval.ApprovalCopier.Result,
+ * LabelTypes)}. This method does the formatting based on the inputs that it gets, but it doesn't do
+ * any verification of these inputs. This means it's possible to provide inputs that are
+ * inconsistent with the approval copying logic in {@link ApprovalCopier}. E.g. it's possible to
+ * provide "is:MAX" as a passing atom for a "Code-Review-1" vote and have "is:MAX" highlighted as
+ * passing in the message although the "Code-Review-1" vote doesn't match with "is:MAX". For easier
+ * readability the formatting tests avoid using such inconsistent input data, but it's not
+ * impossible that in some cases we made a mistake and the input data is inconsistent.
+ */
 public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private ProjectOperations projectOperations;
@@ -98,7 +115,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
@@ -111,7 +132,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Outdated Votes:\n* Code-Review+1 (label type is missing)\n");
   }
@@ -125,7 +150,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1\n");
@@ -141,7 +170,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Outdated Votes:\n* Code-Review+1\n");
   }
@@ -153,13 +186,17 @@
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", -2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MAX"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
-        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+        .hasValue("Copied Votes:\n* Code-Review-2 (copy condition: \"**is:MIN** OR is:MAX\")\n");
   }
 
   @Test
@@ -168,14 +205,21 @@
         new LabelTypes(
             ImmutableList.of(
                 createLabelType(
-                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ "changekind:TRIVIAL_REBASE is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("changekind:TRIVIAL_REBASE"))));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
-        .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+        .hasValue(
+            "Outdated Votes:\n* Code-Review+2 (copy condition:"
+                + " \"changekind:TRIVIAL_REBASE **is:MAX**\")\n");
   }
 
   @Test
@@ -189,7 +233,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -208,7 +256,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
@@ -225,17 +277,22 @@
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -254,12 +311,17 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Outdated Votes:\n"
-                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+                    + " OR (is:MAX **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -275,10 +337,15 @@
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
 
     // Set 'user' as the current user in the request scope.
@@ -291,8 +358,8 @@
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -313,7 +380,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
 
     // Set 'user' as the current user in the request scope.
     // 'user' cannot see the Administrators group that is used in the copy condition.
@@ -325,7 +396,8 @@
         .hasValue(
             String.format(
                 "Outdated Votes:\n"
-                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+                    + " OR (is:MAX **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
   }
 
@@ -344,7 +416,11 @@
     PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -371,7 +447,11 @@
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(),
-            /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
@@ -388,7 +468,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
@@ -401,7 +489,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -417,7 +513,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2 (label type is missing)\n");
@@ -433,7 +537,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1\n");
@@ -450,7 +562,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1\n* Verified+1\n");
@@ -466,7 +586,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2\n");
@@ -480,14 +608,22 @@
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
-    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
-        .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+        .hasValue("Copied Votes:\n* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n");
   }
 
   @Test
@@ -498,39 +634,92 @@
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"),
-                createLabelType(
-                    /* labelName= */ "Verified", /* copyCondition= */ "is:MIN OR is:MAX")));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+                LabelType.builder(
+                        "Verified",
+                        ImmutableList.of(
+                            LabelValue.create((short) -1, "Fails"),
+                            LabelValue.create((short) 0, "No Vote"),
+                            LabelValue.create((short) 1, "Succeeds")))
+                    .setCopyCondition("is:MIN OR is:MAX")
+                    .build()));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             "Copied Votes:\n"
-                + "* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n"
-                + "* Verified+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+                + "* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n"
+                + "* Verified+1 (copy condition: \"is:MIN OR **is:MAX**\")\n");
   }
 
   @Test
-  public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate()
-      throws Exception {
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_samePassingAtoms()
+          throws Exception {
     LabelTypes labelTypes =
         new LabelTypes(
             ImmutableList.of(
                 createLabelType(
-                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "changekind:REWORK")));
     PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             "Copied Votes:\n"
-                + "* Code-Review+1, Code-Review+2 (copy condition: \"is:MIN OR is:MAX\")\n");
+                + "* Code-Review+1, Code-Review+2 (copy condition: \"**changekind:REWORK**\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:1 OR is:2")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:2"),
+                    /* failingAtoms= */ ImmutableSet.of("is:1")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:1"),
+                    /* failingAtoms= */ ImmutableSet.of("is:2"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (copy condition: \"**is:1** OR is:2\")\n"
+                + "* Code-Review+2 (copy condition: \"is:1 OR **is:2**\")\n");
   }
 
   @Test
@@ -545,7 +734,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -565,7 +762,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -587,7 +792,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -597,8 +810,9 @@
   }
 
   @Test
-  public void formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate()
-      throws Exception {
+  public void
+      formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_samePassingAtoms()
+          throws Exception {
     String groupUuid =
         groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
     LabelTypes labelTypes =
@@ -608,24 +822,86 @@
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
-    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s, %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review+2 by %s, %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 groupUuid));
   }
 
   @Test
+  public void
+      formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    String administratorsGroupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    String registeredUsersGroupUuid = SystemGroupBackend.REGISTERED_USERS.get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR (is:MAX approverin:%s)",
+                        administratorsGroupUuid, registeredUsersGroupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", registeredUsersGroupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of(
+                        "is:MIN", String.format("approverin:%s", administratorsGroupUuid))),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX",
+                        String.format("approverin:%s", administratorsGroupUuid),
+                        String.format("approverin:%s", registeredUsersGroupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** approverin:%s)"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                administratorsGroupUuid,
+                registeredUsersGroupUuid,
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                administratorsGroupUuid,
+                registeredUsersGroupUuid));
+  }
+
+  @Test
   public void formatMultipleApprovals_differentLabel_withCopyCondition_withUserInPredicate()
       throws Exception {
     String groupUuid =
@@ -641,18 +917,30 @@
                     /* labelName= */ "Verified",
                     /* copyCondition= */ String.format(
                         "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
-    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", -2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+                    /* failingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid))),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
-                    + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n"
-                    + "* Verified+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + "* Code-Review-2 by %s (copy condition: \"**is:MIN**"
+                    + " OR (is:MAX approverin:%s)\")\n"
+                    + "* Verified+1 by %s (copy condition: \"is:MIN"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 groupUuid,
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
@@ -660,8 +948,9 @@
   }
 
   @Test
-  public void formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate()
-      throws Exception {
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_samePassingAtoms()
+          throws Exception {
     String groupUuid =
         groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
     LabelTypes labelTypes =
@@ -670,51 +959,122 @@
                 createLabelType(
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
-                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+                        "is:MIN OR (is:ANY approverin:%s)", groupUuid))));
     PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
                     + "* Code-Review+1 by %s, Code-Review+2 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
                 groupUuid));
   }
 
   @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:1 approverin:%s) OR (is:2 approverin:%s)",
+                        groupUuid, groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:2", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:1")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:1", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:2"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:1** **approverin:%s**)"
+                    + " OR (is:2 **approverin:%s**)\")\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:1 **approverin:%s**)"
+                    + " OR (**is:2** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid,
+                groupUuid,
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid,
+                groupUuid));
+  }
+
+  @Test
   public void formatMultipleApprovals_differentAndSameValue_withCopyCondition_withUserInPredicate()
       throws Exception {
     TestAccount user2 = accountCreator.user2();
-    String groupUuid =
-        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    String groupUuid = SystemGroupBackend.REGISTERED_USERS.get();
     LabelTypes labelTypes =
         new LabelTypes(
             ImmutableList.of(
                 createLabelType(
                     /* labelName= */ "Code-Review",
                     /* copyCondition= */ String.format(
-                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+                        "is:MIN OR (is:ANY approverin:%s)", groupUuid))));
     PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user2, "Code-Review", 1);
     PatchSetApproval patchSetApproval3 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
             /* copiedApprovals= */ ImmutableSet.of(
-                patchSetApproval1, patchSetApproval2, patchSetApproval3),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval3,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
             String.format(
                 "Copied Votes:\n"
                     + "* Code-Review+1 by %s, %s, Code-Review+2 by %s"
-                    + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+                    + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
                 AccountTemplateUtil.getAccountTemplate(user.id()),
                 AccountTemplateUtil.getAccountTemplate(user2.id()),
                 AccountTemplateUtil.getAccountTemplate(admin.id()),
@@ -737,7 +1097,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -769,7 +1137,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -799,7 +1175,15 @@
     PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
     ApprovalCopier.Result approvalCopierResult =
         ApprovalCopier.Result.create(
-            /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
             /* outdatedApprovals= */ ImmutableSet.of());
     assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
         .hasValue(
@@ -849,7 +1233,7 @@
                 + "\n"
                 + "Copied Votes:\n"
                 + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
-                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
                 + "\n"
                 + "Outdated Votes:\n"
                 + "* Verified+1\n");
@@ -900,7 +1284,7 @@
                 + "\n"
                 + "Copied Votes:\n"
                 + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
-                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
                 + "\n"
                 + "Outdated Votes:\n"
                 + "* Verified+1\n");
@@ -946,7 +1330,7 @@
                 + "\n"
                 + "Copied Votes:\n"
                 + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
-                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
                 + "\n"
                 + "Outdated Votes:\n"
                 + "* Verified+1\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
index 9d0e10a..a055201 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
@@ -38,6 +39,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
@@ -47,8 +49,8 @@
 import org.junit.Test;
 
 /**
- * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewCopyApprovalsOp}
- * copies approvals to follow-up patch sets if possible.
+ * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewOp} copies approvals
+ * to follow-up patch sets if possible.
  */
 public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
@@ -106,9 +108,11 @@
     r.assertOkStatus();
     PatchSet patchSet2 = r.getChange().currentPatchSet();
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified+1");
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-2 Verified-1");
 
     // Verify that no votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -128,8 +132,8 @@
    */
   @Test
   public void newApprovals_copied_noCurrentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -139,9 +143,23 @@
     r.assertOkStatus();
     PatchSet patchSet2 = r.getChange().currentPatchSet();
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
 
     // Verify that the votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -161,8 +179,8 @@
    */
   @Test
   public void newApprovals_notCopied_currentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -176,9 +194,13 @@
     vote(admin, changeId, patchSet2.number(), 2, 1);
     vote(user, changeId, patchSet2.number(), -2, -1);
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify change message.
     vote(admin, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
     vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
 
     // Verify that the votes have not been copied to the current patch set (since a current vote
     // already exists).
@@ -200,8 +222,8 @@
    */
   @Test
   public void newApprovals_notCopied_currentDeletedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -219,9 +241,13 @@
     deleteCurrentVotes(admin, changeId);
     deleteCurrentVotes(user, changeId);
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
     vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
 
     // Verify that the votes have not been copied to the current patch set (since a deletion vote
     // already exists on the current patch set).
@@ -254,9 +280,11 @@
     r.assertOkStatus();
     PatchSet patchSet2 = r.getChange().currentPatchSet();
 
-    // Update the votes on the first patch set.
+    // Update the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified-1");
     vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
 
     // Verify that no votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -278,7 +306,7 @@
   public void updatedApprovals_notCopied_copyingNotEnabled_unsetsCopiedApprovals()
       throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -297,9 +325,30 @@
     assertCurrentVotes(c, admin, 1, 1);
     assertCurrentVotes(c, user, 2, 1);
 
-    // Update the votes on the first patch set with votes that are not copied
+    // Update the votes on the first patch set with votes that are not copied and verify the change
+    // messages.
     vote(admin, changeId, patchSet1.number(), -1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-1 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+                + " since the new Code-Review-1 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review-2 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
 
     // Verify that the copied votes on the current patch set have been unset.
     c = detailedChange(changeId);
@@ -320,7 +369,7 @@
   @Test
   public void updatedApprovals_copied_noCurrentVote() throws Exception {
     updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -339,9 +388,26 @@
     assertCurrentVotes(c, admin, 0, 0);
     assertCurrentVotes(c, user, 0, 0);
 
-    // Update the votes on the first patch set with votes that are copied.
+    // Update the votes on the first patch set with votes that are copied and verify the change
+    // messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
     vote(user, changeId, patchSet1.number(), 1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+1 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+1 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
 
     // Verify that the votes have been copied to the current patch set.
     c = detailedChange(changeId);
@@ -361,8 +427,8 @@
    */
   @Test
   public void updatedApprovals_notCopied_currentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -380,9 +446,11 @@
     vote(admin, changeId, patchSet2.number(), 2, 1);
     vote(user, changeId, patchSet2.number(), -2, -1);
 
-    // Update the votes on the first patch set.
+    // Update the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
     vote(user, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
 
     // Verify that the votes have not been copied to the current patch set (since a current vote
     // already exists).
@@ -404,8 +472,8 @@
    */
   @Test
   public void updatedApprovals_notCopied_currentDeletedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -427,9 +495,11 @@
     deleteCurrentVotes(admin, changeId);
     deleteCurrentVotes(user, changeId);
 
-    // Update the votes on the first patch set.
+    // Update the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
     vote(user, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
 
     // Verify that the votes have not been copied to the current patch set (since a deletion vote
     // already exists on the current patch set).
@@ -451,8 +521,8 @@
    */
   @Test
   public void updatedApprovals_copied_currentCopiedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -471,9 +541,28 @@
     assertCurrentVotes(c, admin, -2, -1);
     assertCurrentVotes(c, user, 2, 1);
 
-    // Update the votes on the first patch set with votes that are copied.
+    // Update the votes on the first patch set with votes that are copied and verify the change
+    // messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 (was Code-Review-2)"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2 (was Verified-1)"
+                + " (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2 (was Code-Review+2)"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2 (was Verified+1)"
+                + " (copy condition: \"is:ANY\")."));
 
     // Verify that the votes have been copied to the current patch set.
     c = detailedChange(changeId);
@@ -509,9 +598,11 @@
     vote(admin, changeId, patchSet2.number(), -2, -1);
     vote(user, changeId, patchSet2.number(), 2, 1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that the vote deletions have not been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -551,9 +642,11 @@
     assertCurrentVotes(c, admin, 0, 0);
     assertCurrentVotes(c, user, 0, 0);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that there are still no votes on the current patch set.
     c = detailedChange(changeId);
@@ -573,8 +666,8 @@
    */
   @Test
   public void deletedApprovals_notCopied_currentVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -592,9 +685,11 @@
     vote(admin, changeId, patchSet2.number(), 2, 1);
     vote(user, changeId, patchSet2.number(), -2, -1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that the vote deletions have not been copied to the current patch set (since a current
     // vote already exists).
@@ -616,8 +711,8 @@
    */
   @Test
   public void deletedApprovals_notCopied_currentDeletedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -639,9 +734,11 @@
     deleteCurrentVotes(admin, changeId);
     deleteCurrentVotes(user, changeId);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
 
     // Verify that there are still no votes on the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -662,8 +759,8 @@
    */
   @Test
   public void deletedApprovals_copied_currentCopiedVote() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -682,9 +779,29 @@
     assertCurrentVotes(c, admin, -2, -1);
     assertCurrentVotes(c, user, 2, 1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review-2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
 
     // Verify that the vote deletions have been copied to the current patch set.
     c = detailedChange(changeId);
@@ -701,8 +818,8 @@
   /** Tests that new approvals on an outdated patch set are copied to all follow-up patch sets. */
   @Test
   public void copyNewApprovalAcrossMultipleFollowUpPatchSets() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -720,9 +837,27 @@
     r.assertOkStatus();
     PatchSet patchSet4 = r.getChange().currentPatchSet();
 
-    // Vote on the first patch set.
+    // Vote on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\")."));
 
     // Verify that votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -748,8 +883,8 @@
   public void
       copyNewApprovalAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
           throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -777,9 +912,25 @@
     assertCurrentVotes(c, admin, 0, -1);
     assertCurrentVotes(c, user, 0, -1);
 
-    // Vote on the first patch set with copyable votes.
+    // Vote on the first patch set with copyable votes and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 "
+                + "(copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+1 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+1 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
 
     // Verify that votes have been not copied to the current patch set.
     c = detailedChange(changeId);
@@ -804,8 +955,8 @@
    */
   @Test
   public void copyApprovalDeletionAcrossMultipleFollowUpPatchSets() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -832,9 +983,33 @@
     assertCurrentVotes(c, admin, 2, 1);
     assertCurrentVotes(c, user, -2, -1);
 
-    // Delete the votes on the first patch set.
+    // Delete the votes on the first patch set and verify the change messages.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set"
+                + " 2 (was Code-Review+2), 3 (was Code-Review+2), 4 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set"
+                + " 2 (was Verified+1), 3 (was Verified+1), 4 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set"
+                + " 2 (was Code-Review-2), 3 (was Code-Review-2), 4 (was Code-Review-2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set"
+                + " 2 (was Verified-1), 3 (was Verified-1), 4 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
 
     // Verify that the votes has been copied to the current patch set.
     c = detailedChange(changeId);
@@ -860,8 +1035,8 @@
   public void
       copyApprovalDeletionAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
           throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -894,7 +1069,27 @@
 
     // Delete the votes on the first patch set.
     vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
 
     // Verify that the vote deletions have been not copied to the current patch set.
     c = detailedChange(changeId);
@@ -917,8 +1112,8 @@
   /** Tests that new approvals on an outdated patch set are not copied to predecessor patch sets. */
   @Test
   public void notCopyToPredecessorPatchSets() throws Exception {
-    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
-    updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -936,9 +1131,23 @@
     r.assertOkStatus();
     PatchSet patchSet4 = r.getChange().currentPatchSet();
 
-    // Vote on the third patch set.
+    // Vote on the third patch set and verify the change messages.
     vote(admin, changeId, patchSet3.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 3: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
     vote(user, changeId, patchSet3.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 3: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
 
     // Verify that votes have been copied to the current patch set.
     ChangeInfo c = detailedChange(changeId);
@@ -1057,4 +1266,10 @@
     assertThat(patchSetApproval.get().value()).isEqualTo((short) expectedVote);
     assertThat(patchSetApproval.get().copied()).isEqualTo(expectedToBeCopied);
   }
+
+  private void assertLastChangeMessage(String changeId, String expectedMessage)
+      throws RestApiException {
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo(expectedMessage);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 9e7a693..bb8f3f3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -16,6 +16,10 @@
 
 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.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.mockito.ArgumentMatchers.any;
@@ -36,13 +40,16 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -100,6 +107,7 @@
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private ProjectOperations projectOperations;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -978,6 +986,36 @@
                 user.fullName()));
   }
 
+  @Test
+  public void votesInChangeMessageAreSorted() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(String.format("Patch Set 1: Code-Review+2 Verified+1"));
+  }
+
   private static class TestListener implements CommentAddedListener {
     public CommentAddedListener.Event lastCommentAddedEvent;
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 7c33ec2..6bd2b68 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -67,6 +67,7 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -74,6 +75,7 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -263,6 +265,38 @@
   }
 
   @Test
+  public void addDuplicatedAccessSection_doesNotAddDuplicateEntry() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // Update project config. Record the file content and the refs_config object ID
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+    ObjectId refsConfigId =
+        projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+    List<String> projectConfigLines =
+        Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+    assertThat(projectConfigLines)
+        .containsExactly(
+            "[submit]",
+            "\taction = inherit",
+            "[access \"refs/heads/*\"]",
+            "\tlabel-Code-Review = deny group Registered Users",
+            "\tlabel-Code-Review = -1..+1 group Project Owners",
+            "\tpush = group Registered Users");
+
+    // Apply the same update once more. Make sure that the file content and the ref did not change
+    pApi().access(accessInput);
+
+    List<String> newProjectConfigLines =
+        Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+    ObjectId newRefsConfigId =
+        projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+    assertThat(projectConfigLines).isEqualTo(newProjectConfigLines);
+    assertThat(refsConfigId).isEqualTo(newRefsConfigId);
+  }
+
+  @Test
   public void addAccessSectionForPluginPermission() throws Exception {
     try (Registration registration =
         extensionRegistry
@@ -325,6 +359,79 @@
   }
 
   @Test
+  public void addAccessSectionWithInvalidLabelRange_minGreaterThanMax() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.min = 1;
+    permissionRuleInfo.max = -1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: 1..-1 (min must be <= max)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelRange_minSetMaxMissing() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.min = -1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: -1.. (max is required if min is set)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelRange_maxSetMinMissing() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.max = 1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: ..1 (min is required if max is set)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
index 52207db..28a0196 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -32,6 +33,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.GroupList;
 import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
@@ -134,6 +137,102 @@
   }
 
   @Test
+  public void rejectCreatingLabelWithInvalidFunction() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  function = INVALID");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: Invalid function for label \"foo\"."
+                + " Valid names are: NoBlock, NoOp, PatchSetLock",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_minGreaterThanMax() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = 1..-1 group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: 1..-1 group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_minSetMaxMissing() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = -1.. group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: -1.. group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_maxSetMinMissing() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = ..1 group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: ..1 group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
   public void rejectSettingCopyMinScore() throws Exception {
     testRejectSettingLabelFlag(
         LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index edfb577..cc72924 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -108,6 +108,7 @@
       "Uploading to an edit worked!".getBytes(UTF_8);
   private static final String CONTENT_BINARY_ENCODED_NEW3 =
       "data:text/plain,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
+  private static final String CONTENT_BINARY_ENCODED_EMPTY = "data:text/plain;base64,";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -697,6 +698,16 @@
   }
 
   @Test
+  public void changeEditModifyFileSetEmptyContentModeRest() throws Exception {
+    createEmptyEditFor(changeId);
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_EMPTY;
+    in.fileMode = FILE_MODE;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
+  }
+
+  @Test
   public void createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
     FileContentInput in = new FileContentInput();
     in.binary_content = CONTENT_BINARY_ENCODED_NEW;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 08f65da..b738324 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1169,6 +1169,27 @@
   }
 
   @Test
+  public void pushForMasterWithForgedAuthorAndCommitter_skipAddingAuthorAndCommitterAsReviewers()
+      throws Exception {
+    setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean.TRUE);
+    TestAccount user2 = accountCreator.user2();
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(user.newIdent())
+            .committer(user2.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Administrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
   public void pushForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
     // Create a commit with different forged author and committer.
     RevCommit c =
@@ -3061,6 +3082,12 @@
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
 
+  @Test
+  public void pushWithInvalidBaseIsRejected() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%base=invalid");
+    r.assertErrorStatus("expected SHA1 for option --base: invalid");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 67c784b..079f84e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -57,8 +57,10 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -494,6 +496,20 @@
   }
 
   @Test
+  public void createAuthorNotAddedAsCcWithAvoidAddingOriginalAuthorAsReviewer() throws Exception {
+    ConfigInput config = new ConfigInput();
+    config.skipAddingAuthorAndCommitterAsReviewers = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(config);
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.email = user.email();
+    input.author.name = user.fullName();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+    assertThat(info.reviewers).isEmpty();
+  }
+
+  @Test
   public void createNewWorkInProgressChange() throws Exception {
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.workInProgress = true;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
index 0d06946..379a712 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -17,18 +17,23 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.assertThatList;
-import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.hasTestId;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.hasTestId;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StandardSubjectBuilder;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
+import com.google.common.truth.Truth8;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -43,6 +48,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -50,6 +56,7 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
 import org.eclipse.jgit.lib.Repository;
@@ -71,6 +78,24 @@
 
   @Before
   public void setup() throws Exception {
+    // Overwrite "Code-Review" label that is inherited from All-Projects.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder codeReview =
+          labelBuilder(
+                  LabelId.CODE_REVIEW,
+                  value(2, "Looks good to me, approved"),
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition(
+                  String.format(
+                      "changekind:%s OR changekind:%s OR is:MIN",
+                      ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
     // Add Verified label.
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType.Builder verified =
@@ -153,6 +178,18 @@
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
             PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+
+    ApprovalDataSubject codeReviewApprovalSubject =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject.hasPassingAtomsThat().isEmpty();
+    codeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+    ApprovalDataSubject verifiedApprovalSubject =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, user.id());
+    verifiedApprovalSubject.hasPassingAtomsThat().isEmpty();
+    verifiedApprovalSubject.hasFailingAtomsThat().containsExactly("is:MIN");
   }
 
   @Test
@@ -176,6 +213,18 @@
             PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
             PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
     assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+
+    ApprovalDataSubject codeReviewApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    codeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+    ApprovalDataSubject verifiedApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+    verifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    verifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
   }
 
   @Test
@@ -230,6 +279,30 @@
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
             PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+
+    ApprovalDataSubject copiedCodeReviewApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    copiedCodeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    copiedCodeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+    ApprovalDataSubject copiedVerifiedApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+    copiedVerifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    copiedVerifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
+
+    ApprovalDataSubject outdatedCodeReviewApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, user.id());
+    outdatedCodeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    outdatedCodeReviewApprovalSubject1
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+    ApprovalDataSubject outdatedVerifiedApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, admin.id());
+    outdatedVerifiedApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    outdatedVerifiedApprovalSubject1.hasFailingAtomsThat().containsExactly("is:MIN");
   }
 
   @Test
@@ -275,6 +348,11 @@
         .comparingElementsUsing(hasTestId())
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+
+    ApprovalDataSubject codeReviewApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    codeReviewApprovalSubject1.hasFailingAtomsThat().isEmpty();
   }
 
   @Test
@@ -347,12 +425,14 @@
     ApprovalCopier.Result approvalCopierResult =
         invokeApprovalCopierForCurrentPatchSet(
             r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
-    ImmutableSet<PatchSetApproval> copiedApprovals = approvalCopierResult.copiedApprovals();
-    assertThatList(filter(copiedApprovals, PatchSetApproval::copied))
+    ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> copiedApprovals =
+        approvalCopierResult.copiedApprovals();
+    assertThatList(filter(copiedApprovals, approval -> approval.patchSetApproval().copied()))
         .comparingElementsUsing(hasTestId())
         .containsExactly(
             PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
-    assertThatList(filter(copiedApprovals, psa -> !psa.copied())).isEmpty();
+    assertThatList(filter(copiedApprovals, approval -> !approval.patchSetApproval().copied()))
+        .isEmpty();
   }
 
   private void vote(String changeId, TestAccount testAccount, String label, int value)
@@ -362,8 +442,9 @@
     requestScopeOperations.setApiUser(admin.id());
   }
 
-  private ImmutableSet<PatchSetApproval> filter(
-      Set<PatchSetApproval> approvals, Predicate<PatchSetApproval> filter) {
+  private ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> filter(
+      Set<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      Predicate<ApprovalCopier.Result.PatchSetApprovalData> filter) {
     return approvals.stream().filter(filter).collect(toImmutableSet());
   }
 
@@ -378,20 +459,75 @@
     }
   }
 
-  public static class PatchSetApprovalSubject extends Subject {
-    public static Correspondence<PatchSetApproval, PatchSetApprovalTestId> hasTestId() {
-      return NullAwareCorrespondence.transforming(PatchSetApprovalTestId::create, "has test ID");
+  public static class ApprovalDataSubject extends Subject {
+    public static Correspondence<ApprovalCopier.Result.PatchSetApprovalData, PatchSetApprovalTestId>
+        hasTestId() {
+      return NullAwareCorrespondence.transforming(
+          approvalData -> PatchSetApprovalTestId.create(approvalData.patchSetApproval()),
+          "has test ID");
     }
 
+    public static ApprovalDataSubject assertThat(
+        ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+      return assertAbout(approvalDatas()).that(approvalData);
+    }
+
+    public static ApprovalDataSubject assertThat(
+        ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+        String labelId,
+        Account.Id accountId) {
+      Optional<ApprovalCopier.Result.PatchSetApprovalData> approvalDataForLabelAndAccount =
+          approvalDatas.stream()
+              .filter(
+                  approvalData ->
+                      approvalData.patchSetApproval().label().equals(labelId)
+                          && approvalData.patchSetApproval().accountId().equals(accountId))
+              .findAny();
+      Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+      return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
+    }
+
+    public static ListSubject<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+        assertThatList(ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas) {
+      return ListSubject.assertThat(approvalDatas.asList(), approvalDatas());
+    }
+
+    private static Factory<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+        approvalDatas() {
+      return ApprovalDataSubject::new;
+    }
+
+    private final ApprovalCopier.Result.PatchSetApprovalData approvalData;
+
+    private ApprovalDataSubject(
+        FailureMetadata metadata, ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+      super(metadata, approvalData);
+      this.approvalData = approvalData;
+    }
+
+    public ListSubject<StringSubject, String> hasPassingAtomsThat() {
+      return check("passingAtoms()")
+          .about(elements())
+          .that(approvalData().passingAtoms().asList(), StandardSubjectBuilder::that);
+    }
+
+    public ListSubject<StringSubject, String> hasFailingAtomsThat() {
+      return check("failingAtoms()")
+          .about(elements())
+          .that(approvalData().failingAtoms().asList(), StandardSubjectBuilder::that);
+    }
+
+    private ApprovalCopier.Result.PatchSetApprovalData approvalData() {
+      isNotNull();
+      return approvalData;
+    }
+  }
+
+  public static class PatchSetApprovalSubject extends Subject {
     public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
       return assertAbout(patchSetApprovals()).that(patchSetApproval);
     }
 
-    public static ListSubject<PatchSetApprovalSubject, PatchSetApproval> assertThatList(
-        ImmutableSet<PatchSetApproval> patchSetApprovals) {
-      return ListSubject.assertThat(patchSetApprovals.asList(), patchSetApprovals());
-    }
-
     private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
       return PatchSetApprovalSubject::new;
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 70b5701..21db45c 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -39,11 +39,13 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
@@ -67,6 +69,8 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 @NoHttpd
@@ -664,6 +668,71 @@
     }
   }
 
+  @Test
+  public void getRelatedLinearSameCommitPushedTwice() throws Exception {
+    RevCommit base = projectOperations.project(project).getHead("master");
+
+    // 1,1---2,1 on master
+    PushOneCommit.Result r1 =
+        createChange(
+            testRepo,
+            "master",
+            "subject: 1",
+            "a.txt",
+            "1",
+            /** topic= */
+            null);
+    RevCommit c1_1 = r1.getCommit();
+    PatchSet.Id ps1_1 = r1.getPatchSetId();
+
+    PushOneCommit.Result r2 =
+        createChange(
+            testRepo,
+            "master",
+            "subject: 2",
+            "b.txt",
+            "2",
+            /** topic= */
+            null);
+    RevCommit c2_1 = r2.getCommit();
+    PatchSet.Id ps2_1 = r2.getPatchSetId();
+
+    // 3,1---4,1 on stable
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+    testRepo.reset(c1_1);
+    PushResult r3 = pushHead(testRepo, "refs/for/stable%base=" + base.getName());
+    assertThat(r3.getRemoteUpdate("refs/for/stable%base=" + base.getName()).getStatus())
+        .isEqualTo(RemoteRefUpdate.Status.OK);
+    ChangeData change3 =
+        Iterables.getOnlyElement(
+            queryProvider
+                .get()
+                .byBranchCommit(BranchNameKey.create(project, "stable"), c1_1.getName()));
+    assertThat(change3.currentPatchSet().commitId()).isEqualTo(c1_1);
+    RevCommit c3_1 = c1_1;
+    PatchSet.Id ps3_1 = change3.currentPatchSet().id();
+
+    PushOneCommit.Result r4 =
+        createChange(
+            testRepo,
+            "stable",
+            "subject: 4",
+            "d.txt",
+            "4",
+            /** topic= */
+            null);
+    RevCommit c4_1 = r4.getCommit();
+    PatchSet.Id ps4_1 = r4.getPatchSetId();
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps4_1, ps3_1)) {
+      assertRelated(ps, changeAndCommit(ps4_1, c4_1, 1), changeAndCommit(ps3_1, c3_1, 1));
+    }
+  }
+
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.transforming(
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index fb3259f..f2184de 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -213,7 +213,7 @@
 
   @Test
   @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "false")
-  public void publishPatchSetLevelComment() throws Exception {
+  public void publishPatchSetLevelComment_disabled() throws Exception {
     PushOneCommit.Result r = createChange();
     TestListener listener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
@@ -225,6 +225,20 @@
   }
 
   @Test
+  @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "true")
+  public void publishPatchSetLevelComment_enabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      String patchSetLevelComment = "a patch set level comment";
+      ReviewInput reviewInput = new ReviewInput().patchSetLevelComment(patchSetLevelComment);
+      revision(r).review(reviewInput);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", patchSetLevelComment));
+    }
+  }
+
+  @Test
   public void reviewChange_MultipleVotes() throws Exception {
     TestListener listener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 7543ba8..c1a7627 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -160,7 +160,8 @@
     Project.NameKey key = projectOperations.newProject().create();
     Config config = projectOperations.project(key).getConfig();
     assertThat(config).isNotInstanceOf(StoredConfig.class);
-    assertThat(config).text().isEmpty();
+    assertThat(config).sections().containsExactly("submit");
+    assertThat(config).sectionValues("submit").containsExactly("action", "inherit");
 
     ConfigInput input = new ConfigInput();
     input.description = "my fancy project";
@@ -168,7 +169,7 @@
 
     config = projectOperations.project(key).getConfig();
     assertThat(config).isNotInstanceOf(StoredConfig.class);
-    assertThat(config).sections().containsExactly("project");
+    assertThat(config).sections().containsExactly("project", "submit");
     assertThat(config).subsections("project").isEmpty();
     assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
   }
@@ -193,7 +194,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -210,7 +211,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -227,7 +228,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -244,7 +245,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -262,7 +263,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -277,7 +278,7 @@
         .update();
 
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -318,7 +319,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -328,31 +329,28 @@
   }
 
   @Test
-  public void addDuplicatePermissions() throws Exception {
+  public void addDuplicatePermissions_isIgnored() throws Exception {
     TestPermission permission =
         TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
     Project.NameKey key = projectOperations.newProject().create();
     projectOperations.project(key).forUpdate().add(permission).add(permission).update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().contains("access");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
-        .containsExactly(
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users");
+        // Duplicated permission was recorded only once
+        .containsExactly("abandon", "group global:Registered-Users");
 
     projectOperations.project(key).forUpdate().add(permission).update();
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
-        .containsExactly(
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users");
+        // Duplicated permission in request was dropped
+        .containsExactly("abandon", "group global:Registered-Users");
   }
 
   @Test
@@ -365,7 +363,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -382,7 +380,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -400,7 +398,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -415,7 +413,7 @@
         .update();
 
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -437,7 +435,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
index 29fd5ed..1f725f8 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -40,6 +40,9 @@
           .setBooleanConfig(
               BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
               InheritableBoolean.INHERIT)
+          .setBooleanConfig(
+              BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+              InheritableBoolean.TRUE)
           .build();
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
index 6a62ed1..dacc37d 100644
--- a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.testing.TestIndexedFields.EXACT_STRING_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD;
 import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_RANGE_FIELD_SPEC;
@@ -31,6 +32,7 @@
 import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.LONG_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.PREFIX_STRING_FIELD_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_FIELD;
 import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_SPEC;
 import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD;
@@ -78,6 +80,8 @@
               .put(ITERABLE_LONG_RANGE_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
               .put(TIMESTAMP_FIELD_SPEC, new Timestamp(1234567L))
               .put(STRING_FIELD_SPEC, "123456")
+              .put(PREFIX_STRING_FIELD_SPEC, "123456")
+              .put(EXACT_STRING_FIELD_SPEC, "123456")
               .put(ITERABLE_STRING_FIELD_SPEC, ImmutableList.of("123456"))
               .put(
                   ITERABLE_STORED_BYTE_SPEC,
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 683638e..ce9ce2d 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -249,6 +249,13 @@
    */
   use_lit_components?: boolean;
   show_sign_col?: boolean;
+  /**
+   * The default view mode is SIDE_BY_SIDE.
+   *
+   * Note that gr-diff also still supports setting viewMode as a dedicated
+   * property on <gr-diff>. TODO: Migrate usages to RenderPreferences.
+   */
+  view_mode?: DiffViewMode;
 }
 
 /**
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 283e029..a09f711 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -15,7 +15,10 @@
   PUT = 'PUT',
 }
 
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+export type ErrorCallback = (
+  response?: Response | null,
+  err?: Error
+) => Promise<void> | void;
 
 export declare interface RestPluginApi {
   getLoggedIn(): Promise<boolean>;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 1c62114..1be5fa3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -129,6 +129,7 @@
 
   constructor() {
     super();
+    this.addEventListener('reload', () => window.location.reload());
     subscribe(
       this,
       () => this.getAdminViewModel().state$,
@@ -418,7 +419,10 @@
 
     return html`
       <div class="main breadcrumbs">
-        <gr-repo-commands .repo=${this.repoViewState.repo}></gr-repo-commands>
+        <gr-repo-commands
+          .repo=${this.repoViewState.repo}
+          .createEdit=${this.repoViewState.createEdit}
+        ></gr-repo-commands>
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index cee0fa4..3254b5c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -29,6 +29,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -241,7 +242,13 @@
       input = input.substring(REF_PREFIX.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.repoName,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
new file mode 100644
index 0000000..c3f79c7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  RepoName,
+  BranchName,
+  ChangeInfo,
+  PatchSetNumber,
+} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {createEditUrl} from '../../../models/views/edit';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when.js';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-file-edit-dialog': GrCreateFileEditDialog;
+  }
+}
+
+@customElement('gr-create-file-edit-dialog')
+export class GrCreateFileEditDialog extends LitElement {
+  @query('dialog')
+  modal?: HTMLDialogElement;
+
+  @query('gr-dialog')
+  grDialog?: GrDialog;
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: String})
+  path?: string;
+
+  /**
+   * If this is set, then we show this message replacing all other content.
+   */
+  @state()
+  errorMessage?: string;
+
+  /**
+   * Triggers showing the dialog and kicks off creating a change.
+   */
+  @state()
+  active = false;
+
+  /**
+   * Indicates whether the REST API call for creating a change is in progress.
+   */
+  @state()
+  loading = false;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  static override get styles() {
+    return [modalStyles];
+  }
+
+  override render() {
+    if (!this.active) return nothing;
+    return html`
+      <dialog tabindex="-1">
+        <gr-dialog
+          disabled
+          ?loading=${this.loading}
+          .loadingLabel=${'Creating change ...'}
+          @cancel=${() => this.deactivate()}
+          .confirmLabel=${this.loading ? 'Please wait ...' : 'Failed'}
+          .cancelLabel=${'Cancel'}
+        >
+          <div slot="header">
+            <span class="main-heading">Create Change from URL</span>
+          </div>
+          <div slot="main">
+            ${when(
+              this.errorMessage,
+              () => this.renderError(),
+              () => this.renderCreating()
+            )}
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  async activate() {
+    this.active = true;
+    this.createChange();
+    await this.updateComplete;
+    if (this.active && this.modal?.open === false) this.modal.showModal();
+  }
+
+  deactivate() {
+    this.active = false;
+    this.modal?.close();
+  }
+
+  private renderCreating() {
+    return html`
+      <div>
+        <span>
+          Creating a change in repository <b>${this.repo}</b> on branch
+          <b>${this.branch}</b>.
+        </span>
+      </div>
+      <div>
+        <span>
+          The page will then redirect to the file editor for
+          <b>${this.path}</b>
+          in the newly created change.
+        </span>
+      </div>
+    `;
+  }
+
+  private renderError() {
+    return html`<div>Error: ${this.errorMessage}</div>`;
+  }
+
+  private createChange() {
+    if (!this.repo || !this.branch || !this.path) {
+      this.errorMessage = 'repo, branch and path must be set';
+      return;
+    }
+    if (this.loading || this.errorMessage) return;
+    this.loading = true;
+    this.restApiService
+      .createChange(this.repo, this.branch, `Edit ${this.path}`)
+      .then(change => {
+        if (!this.active) return;
+        if (change) {
+          this.loading = false;
+          this.redirectToFileEdit(change);
+          this.deactivate();
+        } else {
+          this.errorMessage = 'Creating the change failed.';
+        }
+      })
+      .catch(() => {
+        this.errorMessage = 'Creating the change failed.';
+      })
+      .finally(() => {
+        this.loading = false;
+      });
+  }
+
+  private redirectToFileEdit(change: ChangeInfo) {
+    assertIsDefined(this.path, 'path');
+    const url = createEditUrl({
+      changeNum: change._number,
+      repo: change.project,
+      path: this.path,
+      patchNum: 1 as PatchSetNumber,
+    });
+    this.getNavigation().setUrl(url);
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
new file mode 100644
index 0000000..7621fac
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-create-file-edit-dialog';
+import {createChange} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCreateFileEditDialog} from './gr-create-file-edit-dialog';
+import {stubRestApi, waitUntilCalled} from '../../../test/test-utils';
+import {BranchName, RepoName} from '../../../api/rest-api';
+import {SinonStub} from 'sinon';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+
+suite('gr-create-file-edit-dialog', () => {
+  let element: GrCreateFileEditDialog;
+  let createChangeStub: SinonStub;
+  let setUrlStub: SinonStub;
+
+  setup(async () => {
+    createChangeStub = stubRestApi('createChange').resolves(createChange());
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+    element = await fixture(
+      html`<gr-create-file-edit-dialog></gr-create-file-edit-dialog>`
+    );
+    element.repo = 'test-repo' as RepoName;
+    element.branch = 'test-branch' as BranchName;
+    element.path = 'test-path';
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.activate();
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog tabindex="-1">
+          <gr-dialog disabled loading>
+            <div slot="header">
+              <span class="main-heading"> Create Change from URL </span>
+            </div>
+            <div slot="main">
+              <div>
+                <span>
+                  Creating a change in repository
+                  <b> test-repo </b>
+                  on branch
+                  <b> test-branch </b>
+                  .
+                </span>
+              </div>
+              <div>
+                <span>
+                  The page will then redirect to the file editor for
+                  <b> test-path </b> in the newly created change.
+                </span>
+              </div>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('render error', async () => {
+    element.activate();
+    element.errorMessage = 'Failed.';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog tabindex="-1">
+          <gr-dialog disabled loading>
+            <div slot="header">
+              <span class="main-heading"> Create Change from URL </span>
+            </div>
+            <div slot="main">
+              <div>Error: Failed.</div>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('creates change', async () => {
+    element.activate();
+    await element.updateComplete;
+
+    assert.isTrue(createChangeStub.calledOnce);
+    await waitUntilCalled(setUrlStub, 'setUrl');
+    await element.updateComplete;
+    assert.shadowDom.equal(element, '');
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 889a859..558d571 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -6,8 +6,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
@@ -15,7 +13,7 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {RepoDetailView} from '../../../models/views/repo';
 
 declare global {
@@ -126,13 +124,13 @@
       throw new Error('itemName name is not set');
     }
     const USE_HEAD = this.itemRevision ? this.itemRevision : 'HEAD';
-    const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
     if (this.itemDetail === RepoDetailView.BRANCHES) {
       return this.restApiService
         .createRepoBranch(this.repoName, this.itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
-            page.show(`${url},branches`);
+            fireAlert(this, 'Branch created successfully. Reloading...');
+            fireReload(this);
           }
         });
     } else if (this.itemDetail === RepoDetailView.TAGS) {
@@ -143,7 +141,8 @@
         })
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
-            page.show(`${url},tags`);
+            fireAlert(this, 'Tag created successfully. Reloading...');
+            fireReload(this);
           }
         });
     }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index c88b1c5..a90abbd 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -23,6 +23,7 @@
 import {LitElement, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {fireEvent} from '../../../utils/event-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -204,7 +205,11 @@
   }
 
   private async getRepoSuggestions(input: string) {
-    const response = await this.restApiService.getSuggestedRepos(input);
+    const response = await this.restApiService.getSuggestedRepos(
+      input,
+      /* n=*/ undefined,
+      throwingErrorCallback
+    );
 
     const repos = [];
     for (const [name, repo] of Object.entries(response ?? {})) {
@@ -214,7 +219,12 @@
   }
 
   private async getGroupSuggestions(input: string) {
-    const response = await this.restApiService.getSuggestedGroups(input);
+    const response = await this.restApiService.getSuggestedGroups(
+      input,
+      /* project=*/ undefined,
+      /* n=*/ undefined,
+      throwingErrorCallback
+    );
 
     const groups = [];
     for (const [name, group] of Object.entries(response ?? {})) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 8824009..af9977b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -42,6 +42,7 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SAVING_ERROR_TEXT =
   'Group may not exist, or you may not have ' + 'permission to add it';
@@ -489,7 +490,7 @@
           if (errResponse) {
             if (errResponse.status === 404) {
               fireAlert(this, SAVING_ERROR_TEXT);
-              return errResponse;
+              return;
             }
             throw Error(errResponse.statusText);
           }
@@ -529,13 +530,20 @@
 
   /* private but used in test */
   getGroupSuggestions(input: string) {
-    return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups: AutocompleteSuggestion[] = [];
-      for (const [name, group] of Object.entries(response ?? {})) {
-        groups.push({name, value: decodeURIComponent(group.id)});
-      }
-      return groups;
-    });
+    return this.restApiService
+      .getSuggestedGroups(
+        input,
+        /* project=*/ undefined,
+        /* n=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(response => {
+        const groups: AutocompleteSuggestion[] = [];
+        for (const [name, group] of Object.entries(response ?? {})) {
+          groups.push({name, value: decodeURIComponent(group.id)});
+        }
+        return groups;
+      });
   }
 
   private handleGroupMemberTextChanged(e: CustomEvent) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index e65b16b..ba2b3fd 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -24,6 +24,7 @@
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -427,13 +428,20 @@
   }
 
   private getGroupSuggestions(input: string) {
-    return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups: AutocompleteSuggestion[] = [];
-      for (const [name, group] of Object.entries(response ?? {})) {
-        groups.push({name, value: decodeURIComponent(group.id)});
-      }
-      return groups;
-    });
+    return this.restApiService
+      .getSuggestedGroups(
+        input,
+        /* project=*/ undefined,
+        /* n=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(response => {
+        const groups: AutocompleteSuggestion[] = [];
+        for (const [name, group] of Object.entries(response ?? {})) {
+          groups.push({name, value: decodeURIComponent(group.id)});
+        }
+        return groups;
+      });
   }
 
   // private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 49fa99f..07b7c87 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -41,6 +41,7 @@
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {when} from 'lit/directives/when.js';
 import {ValueChangedEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -471,7 +472,8 @@
       .getSuggestedGroups(
         this.groupFilter || '',
         this.repo,
-        MAX_AUTOCOMPLETE_RESULTS
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
       )
       .then(response => {
         const groups: GroupSuggestion[] = [];
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 27deb82..389e7c4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -43,6 +43,7 @@
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -397,7 +398,12 @@
 
   private getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getRepos(this.inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+      .getRepos(
+        this.inheritFromFilter,
+        MAX_AUTOCOMPLETE_RESULTS,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         const repos: AutocompleteSuggestion[] = [];
         if (!response) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index bb1e926..e13609e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -9,6 +9,7 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
+import '../gr-create-change-dialog/gr-create-file-edit-dialog';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchName,
@@ -34,6 +35,7 @@
 import {createEditUrl} from '../../../models/views/edit';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrCreateFileEditDialog} from '../gr-create-change-dialog/gr-create-file-edit-dialog';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -57,9 +59,18 @@
   @query('#createNewChangeModal')
   private readonly createNewChangeModal?: GrCreateChangeDialog;
 
+  @query('#createFileEditDialog')
+  private readonly createFileEditDialog?: GrCreateFileEditDialog;
+
   @property({type: String})
   repo?: RepoName;
 
+  @property({type: Object})
+  createEdit?: {
+    branch: BranchName;
+    path: string;
+  };
+
   @state() private loading = true;
 
   @state() private repoConfig?: ConfigInfo;
@@ -76,6 +87,9 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  /** Make sure that this dialog is only activated once. */
+  private createFileEditDialogWasActivated = false;
+
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Repo Commands');
@@ -181,6 +195,12 @@
           </div>
         </gr-dialog>
       </dialog>
+      <gr-create-file-edit-dialog
+        id="createFileEditDialog"
+        .repo=${this.repo}
+        .branch=${this.createEdit?.branch}
+        .path=${this.createEdit?.path}
+      ></gr-create-file-edit-dialog>
     `;
   }
 
@@ -200,6 +220,15 @@
     `;
   }
 
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('createEdit')) {
+      if (!this.createFileEditDialogWasActivated) {
+        this.createFileEditDialog?.activate();
+        this.createFileEditDialogWasActivated = true;
+      }
+    }
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('repo')) {
       this.loadRepo();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
index 01140c6..dab2706 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -94,6 +94,8 @@
             </div>
           </gr-dialog>
         </dialog>
+        <gr-create-file-edit-dialog id="createFileEditDialog">
+        </gr-create-file-edit-dialog>
       `,
       {ignoreTags: ['p']}
     );
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
index ad1f718..f8fcad8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -27,6 +27,7 @@
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 @customElement('gr-change-list-hashtag-flow')
 export class GrChangeListHashtagFlow extends LitElement {
@@ -298,7 +299,8 @@
     query: string
   ): Promise<AutocompleteSuggestion[]> {
     const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
-      query
+      query,
+      throwingErrorCallback
     );
     this.existingHashtagSuggestions = (suggestions ?? [])
       .flatMap(change => change.hashtags ?? [])
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index 363b1ae..7752476 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -28,6 +28,7 @@
 import {fireAlert} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 @customElement('gr-change-list-topic-flow')
 export class GrChangeListTopicFlow extends LitElement {
@@ -343,7 +344,8 @@
     query: string
   ): Promise<AutocompleteSuggestion[]> {
     const suggestions = await this.restApiService.getChangesWithSimilarTopic(
-      query
+      query,
+      throwingErrorCallback
     );
     this.existingTopicSuggestions = (suggestions ?? [])
       .map(change => change.topic)
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 cd5cb96..7abfce9 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
@@ -294,7 +294,13 @@
   // private but used in test
   computePage() {
     if (this.offset === undefined || this.changesPerPage === undefined) return;
-    return this.offset / this.changesPerPage + 1;
+    // We use Math.ceil in case the offset is not divisible by changesPerPage.
+    // If we did not do this, you'd have page '1.2' and then when pressing left
+    // arrow 'Page 1'.  This way page '1.2' becomes page '2'.
+    return (
+      Math.ceil(this.offset / this.limitFor(this.query, this.changesPerPage)) +
+      1
+    );
   }
 
   private async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
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 329102a..adc3bd7 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
@@ -1570,13 +1570,10 @@
   handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
     assertIsDefined(this.confirmRebase, 'confirmRebase');
     assertIsDefined(this.actionsModal, 'actionsModal');
-    const el = this.confirmRebase;
     const payload = {
       base: e.detail.base,
       allow_conflicts: e.detail.allowConflicts,
     };
-    this.actionsModal.close();
-    el.hidden = true;
     this.fireAction(
       '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
@@ -1841,6 +1838,7 @@
     if (!response) {
       return;
     }
+    // response is guaranteed to be ok (due to semantics of rest-api methods)
     return this.restApiService.getResponseObject(response).then(obj => {
       switch (action.__key) {
         case ChangeActions.REVERT: {
@@ -1874,6 +1872,9 @@
         case ChangeActions.REBASE_EDIT:
         case ChangeActions.REBASE:
         case ChangeActions.SUBMIT:
+          // Hide rebase dialog only if the action succeeds
+          this.actionsModal?.close();
+          this.hideAllDialogs();
           fireReload(this, true);
           break;
         case ChangeActions.REVERT_SUBMISSION: {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 1d10128..a1021a0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -1422,6 +1422,39 @@
         await element.reload();
       });
 
+      test('revert change payload', async () => {
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
+          element,
+          'gr-button[data-action-key="revert"]'
+        ).click();
+        const revertAction = {
+          __key: 'revert',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Revert',
+          title: 'Revert the change',
+          enabled: true,
+        };
+        queryAndAssert(element, 'gr-confirm-revert-dialog').dispatchEvent(
+          new CustomEvent('confirm', {
+            detail: {
+              message: 'foo message',
+              revertType: 1,
+            },
+          })
+        );
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/revert',
+          assertUIActionInfo(revertAction),
+          false,
+          {
+            message: 'foo message',
+          },
+        ]);
+      });
+
       test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
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 6a74dce..a06f8c9 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
@@ -81,6 +81,7 @@
 import {createSearchUrl} from '../../../models/views/search';
 import {createChangeUrl} from '../../../models/views/change';
 import {GeneratedWebLink, getChangeWeblinks} from '../../../utils/weblink-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -279,7 +280,7 @@
       ${this.renderNonOwner(ChangeRole.AUTHOR)}
       ${this.renderNonOwner(ChangeRole.COMMITTER)} ${this.renderReviewers()}
       ${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
-      ${this.renderMergedAs()} ${this.renderShowReverCreatedAs()}
+      ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
       ${this.renderTopic()} ${this.renderCherryPickOf()}
       ${this.renderStrategy()} ${this.renderHashTags()}
       ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
@@ -561,7 +562,7 @@
     </section>`;
   }
 
-  private renderShowReverCreatedAs() {
+  private renderShowRevertCreatedAs() {
     if (!this.showRevertCreatedAs()) return nothing;
 
     return html`<section
@@ -1141,7 +1142,7 @@
     input: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getChangesWithSimilarTopic(input)
+      .getChangesWithSimilarTopic(input, throwingErrorCallback)
       .then(response =>
         (response ?? [])
           .map(change => change.topic)
@@ -1157,7 +1158,7 @@
     input: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getChangesWithSimilarHashtag(input)
+      .getChangesWithSimilarHashtag(input, throwingErrorCallback)
       .then(response =>
         (response ?? [])
           .flatMap(change => change.hashtags ?? [])
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 c4c37fa..fe2bb4d 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
@@ -923,6 +923,7 @@
         }
         .showCopyLinkDialogButton {
           --gr-button-padding: 0 0 0 var(--spacing-s);
+          --background-color: transparent;
           margin-left: var(--spacing-s);
         }
         #replyBtn {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index ffb180d..5d7e55c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -37,6 +37,7 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {createSearchUrl} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -631,7 +632,13 @@
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.project,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 7adc2ca..8f5c8dc 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -14,6 +14,7 @@
 import {Key, Modifier} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 
@@ -163,7 +164,13 @@
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.project,
+        SUGGESTIONS_LIMIT,
+        /* offest=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 14cd5e8..072ec73d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -16,6 +16,7 @@
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ValueChangedEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export interface RebaseChange {
   name: string;
@@ -222,7 +223,13 @@
   // last time it was run.
   fetchRecentChanges() {
     return this.restApiService
-      .getChanges(undefined, 'is:open -age:90d')
+      .getChanges(
+        undefined,
+        'is:open -age:90d',
+        /* offset=*/ undefined,
+        /* options=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index cf66ecf..1c48a42 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -127,7 +127,7 @@
     return html`
       <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
       Your unpublished edit will not be submitted. Did you forget to click
-      <b>PUBLISH</b>
+      <b>PUBLISH</b> after pressing <b>EDIT</b>?
     `;
   }
 
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 9bac7c2..89a1ff5 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
@@ -268,10 +268,6 @@
       this.allPatchSets
     );
     const expandedClass = this.computeExpandedClass(this.filesExpanded);
-    const prefsButtonHidden = this.computePrefsButtonHidden(
-      this.diffPrefs,
-      this.loggedIn
-    );
     return html`
       <div class="patchInfo-header ${editModeClass} ${patchInfoClass}">
         <div class="patchInfo-left">
@@ -309,28 +305,29 @@
               </span>
             `
           )}
-          <div class="fileViewActions">
-            <span class="fileViewActionsLabel">Diff view:</span>
-            <gr-diff-mode-selector
-              id="modeSelect"
-              .saveOnChange=${this.loggedIn ?? false}
-            ></gr-diff-mode-selector>
-            <span
-              id="diffPrefsContainer"
-              class="hideOnEdit"
-              ?hidden=${prefsButtonHidden}
-            >
-              <gr-tooltip-content has-tooltip title="Diff preferences">
-                <gr-button
-                  link
-                  class="prefsButton desktop"
-                  @click=${this.handlePrefsTap}
-                  ><gr-icon icon="settings" filled></gr-icon
-                ></gr-button>
-              </gr-tooltip-content>
-            </span>
-            <span class="separator"></span>
-          </div>
+          ${when(
+            this.loggedIn && this.diffPrefs,
+            () => html`
+              <div class="fileViewActions">
+                <span class="fileViewActionsLabel">Diff view:</span>
+                <gr-diff-mode-selector
+                  id="modeSelect"
+                  .saveOnChange=${true}
+                ></gr-diff-mode-selector>
+                <span id="diffPrefsContainer" class="hideOnEdit">
+                  <gr-tooltip-content has-tooltip title="Diff preferences">
+                    <gr-button
+                      link
+                      class="prefsButton desktop"
+                      @click=${this.handlePrefsTap}
+                      ><gr-icon icon="settings" filled></gr-icon
+                    ></gr-button>
+                  </gr-tooltip-content>
+                </span>
+                <span class="separator"></span>
+              </div>
+            `
+          )}
           <span class="downloadContainer desktop">
             <gr-tooltip-content
               has-tooltip
@@ -402,13 +399,6 @@
     return classes.join(' ');
   }
 
-  private computePrefsButtonHidden(
-    prefs: DiffPreferencesInfo,
-    loggedIn?: boolean
-  ) {
-    return !loggedIn || !prefs;
-  }
-
   private fileListActionsVisible(
     shownFileCount: number,
     maxFilesForBulkActions: number
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index 48cef64..23534a0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -53,6 +53,7 @@
         .shownFileCount=${3}
       ></gr-file-list-header>`
     );
+    element.loggedIn = true;
     element.diffPrefs = createDefaultDiffPrefs();
     await element.updateComplete;
   });
@@ -77,7 +78,7 @@
             <div class="fileViewActions">
               <span class="fileViewActionsLabel"> Diff view: </span>
               <gr-diff-mode-selector id="modeSelect"> </gr-diff-mode-selector>
-              <span class="hideOnEdit" hidden="" id="diffPrefsContainer">
+              <span class="hideOnEdit" id="diffPrefsContainer">
                 <gr-tooltip-content has-tooltip="" title="Diff preferences">
                   <gr-button
                     aria-disabled="false"
@@ -110,7 +111,7 @@
             </span>
             <gr-tooltip-content
               has-tooltip=""
-              title="Show/hide all inline diffs (shortcut: I)"
+              title="Show/hide all inline diffs (shortcut: Shift+i)"
             >
               <gr-button
                 aria-disabled="false"
@@ -124,7 +125,7 @@
             </gr-tooltip-content>
             <gr-tooltip-content
               has-tooltip=""
-              title="Show/hide all inline diffs (shortcut: I)"
+              title="Show/hide all inline diffs (shortcut: Shift+i)"
             >
               <gr-button
                 aria-disabled="false"
@@ -143,17 +144,13 @@
   });
 
   test('Diff preferences hidden when no prefs', async () => {
-    assert.isTrue(
-      queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
-    );
+    assert.isOk(query<HTMLElement>(element, '#diffPrefsContainer'));
 
-    element.diffPrefs = createDefaultDiffPrefs();
+    element.diffPrefs = undefined;
     element.loggedIn = true;
     await element.updateComplete;
 
-    assert.isFalse(
-      queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
-    );
+    assert.isNotOk(query<HTMLElement>(element, '#diffPrefsContainer'));
   });
 
   test('expandAllDiffs called when expand button clicked', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 69fd142..a2beee2 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -99,7 +99,7 @@
   override render() {
     const change = this.change;
     if (!change) throw new Error('Missing change');
-    const linkClass = this._computeLinkClass(change);
+    const linkClass = this.computeLinkClass(change);
     return html`
       <div class="changeContainer">
         <a
@@ -118,16 +118,16 @@
               >✓</span
             >`
           : ''}
-        ${this.showChangeStatus && !isChangeInfo(change)
-          ? html`<span class=${this._computeChangeStatusClass(change)}>
-              (${this._computeChangeStatus(change)})
+        ${this.showChangeStatus
+          ? html`<span class=${this.computeChangeStatusClass(change)}>
+              (${this.computeChangeStatus(change)})
             </span>`
           : ''}
       </div>
     `;
   }
 
-  _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
+  private computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
     const statuses = [];
     if (change.status === ChangeStatus.ABANDONED) {
       statuses.push('strikethrough');
@@ -138,11 +138,16 @@
     return statuses.join(' ');
   }
 
-  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+  private computeChangeStatusClass(
+    change: RelatedChangeAndCommitInfo | ChangeInfo
+  ) {
     const classes = ['status'];
-    if (change._revision_number !== change._current_revision_number) {
+    if (
+      !isChangeInfo(change) &&
+      change._revision_number !== change._current_revision_number
+    ) {
       classes.push('notCurrent');
-    } else if (this._isIndirectAncestor(change)) {
+    } else if (!isChangeInfo(change) && this.isIndirectAncestor(change)) {
       classes.push('indirectAncestor');
     } else if (change.submittable) {
       classes.push('submittable');
@@ -152,16 +157,19 @@
     return classes.join(' ');
   }
 
-  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+  private computeChangeStatus(change: RelatedChangeAndCommitInfo | ChangeInfo) {
     switch (change.status) {
       case ChangeStatus.MERGED:
         return 'Merged';
       case ChangeStatus.ABANDONED:
         return 'Abandoned';
     }
-    if (change._revision_number !== change._current_revision_number) {
+    if (
+      !isChangeInfo(change) &&
+      change._revision_number !== change._current_revision_number
+    ) {
       return 'Not current';
-    } else if (this._isIndirectAncestor(change)) {
+    } else if (!isChangeInfo(change) && this.isIndirectAncestor(change)) {
       return 'Indirect ancestor';
     } else if (change.submittable) {
       return 'Submittable';
@@ -169,7 +177,7 @@
     return '';
   }
 
-  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+  private isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
     return (
       this.connectedRevisions &&
       !this.connectedRevisions.includes(change.commit.commit)
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 bdb95ea..cac9ae5 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
@@ -418,6 +418,7 @@
               )}<gr-related-change
                 .change=${change}
                 .href=${createChangeUrl({change, usp: 'cherry-pick'})}
+                show-change-status
                 >${change.branch}: ${change.subject}</gr-related-change
               >
             </div>`
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 676a468..3e90145 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -249,7 +249,7 @@
               <gr-related-collapse title="Cherry picks">
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
-                  <gr-related-change>
+                  <gr-related-change show-change-status="">
                     test-branch: Test subject
                   </gr-related-change>
                 </div>
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 29bd1d9..767c4cf 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -75,6 +75,7 @@
 } from '../../../utils/common-util';
 import {
   CommentThread,
+  createPatchsetLevelUnsavedDraft,
   DraftInfo,
   getFirstComment,
   isDraft,
@@ -111,7 +112,7 @@
   LabelNameToValuesMap,
   PatchSetNumber,
 } from '../../../api/rest-api';
-import {css, html, PropertyValues, LitElement} from 'lit';
+import {css, html, PropertyValues, LitElement, nothing} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {when} from 'lit/directives/when.js';
 import {classMap} from 'lit/directives/class-map.js';
@@ -606,6 +607,16 @@
       .patchsetLevelContainer.unresolved {
         background-color: var(--unresolved-comment-background-color);
       }
+      .privateVisiblityInfo {
+        display: flex;
+        justify-content: center;
+        background-color: var(--info-background);
+        padding: var(--spacing-s) 0;
+      }
+      .privateVisiblityInfo gr-icon {
+        margin-right: var(--spacing-m);
+        color: var(--info-foreground);
+      }
     `,
   ];
 
@@ -791,6 +802,7 @@
             <gr-endpoint-slot name="below"></gr-endpoint-slot>
           </gr-endpoint-decorator>
           ${this.renderCCList()} ${this.renderReviewConfirmation()}
+          ${this.renderPrivateVisiblityInfo()}
         </section>
         <section class="labelsContainer">${this.renderLabels()}</section>
         <section class="newReplyDialog textareaContainer">
@@ -893,6 +905,22 @@
     `;
   }
 
+  private renderPrivateVisiblityInfo() {
+    const addedAccounts = [
+      ...(this.reviewersList?.additions() ?? []),
+      ...(this.ccsList?.additions() ?? []),
+    ];
+    if (!this.change?.is_private || !addedAccounts.length) return nothing;
+    return html`
+      <div class="privateVisiblityInfo">
+        <gr-icon icon="info"></gr-icon>
+        <div>
+          Adding a reviewer/CC will make this private change visible to them
+        </div>
+      </div>
+    `;
+  }
+
   private renderLabels() {
     if (!this.change || !this.account || !this.permittedLabels) return;
     return html`
@@ -913,20 +941,13 @@
     `;
   }
 
-  // TODO: move to comment-util
-  private createDraft(): UnsavedInfo {
-    return {
-      patch_set: this.latestPatchNum,
-      message: this.patchsetLevelDraftMessage,
-      unresolved: !this.patchsetLevelDraftIsResolved,
-      path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-      __unsaved: true,
-    };
-  }
-
   private renderPatchsetLevelComment() {
     if (!this.patchsetLevelComment)
-      this.patchsetLevelComment = this.createDraft();
+      this.patchsetLevelComment = createPatchsetLevelUnsavedDraft(
+        this.latestPatchNum,
+        this.patchsetLevelDraftMessage,
+        !this.patchsetLevelDraftIsResolved
+      );
     return html`
       <gr-comment
         id="patchsetLevelComment"
@@ -1386,6 +1407,14 @@
 
     if (startReview) {
       reviewInput.ready = true;
+    } else if (this.change?.work_in_progress) {
+      const addedAccounts = [
+        ...(this.reviewersList?.additions() ?? []),
+        ...(this.ccsList?.additions() ?? []),
+      ];
+      if (addedAccounts.length > 0) {
+        fireAlert(this, 'Reviewers are not notified for WIP changes');
+      }
     }
 
     this.disabled = true;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 633c5b0..acb7c83 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -211,10 +211,7 @@
               <div class="peopleListLabel">CC</div>
               <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
             </div>
-            <dialog
-              tabindex="-1"
-              id="reviewerConfirmationModal"
-            >
+            <dialog tabindex="-1" id="reviewerConfirmationModal">
               <div class="reviewerConfirmation">
                 Group
                 <span class="groupName"> </span>
@@ -232,7 +229,7 @@
                   No
                 </gr-button>
               </div>
-            </gr-overlay>
+            </dialog>
           </section>
           <section class="labelsContainer">
             <gr-endpoint-decorator name="reply-label-scores">
@@ -330,6 +327,123 @@
     );
   });
 
+  test('renders private change info when reviewer is added', async () => {
+    element.change!.is_private = true;
+    element.requestUpdate();
+    await element.updateComplete;
+    const peopleContainer = queryAndAssert<HTMLDivElement>(
+      element,
+      '.peopleContainer'
+    );
+
+    // Info is rendered only if reviewer is added
+    assert.dom.equal(
+      peopleContainer,
+      `
+      <section class="peopleContainer">
+        <gr-endpoint-decorator name="reply-reviewers">
+          <gr-endpoint-param name="change"> </gr-endpoint-param>
+          <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+          <div class="peopleList">
+            <div class="peopleListLabel">Reviewers</div>
+            <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+          </div>
+          <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+        </gr-endpoint-decorator>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+        </div>
+        <dialog
+          tabindex="-1"
+          id="reviewerConfirmationModal"
+        >
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName"> </span>
+            has
+            <span class="groupSize"> </span>
+            members.
+            <br />
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Yes
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              No
+            </gr-button>
+          </div>
+        </dialog>
+      </section>
+    `
+    );
+
+    const account = createAccountWithId(22);
+    element.reviewersList!.accounts = [];
+    element.reviewersList!.addAccountItem({account, count: 1});
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account},
+      })
+    );
+    element.requestUpdate();
+    await element.updateComplete;
+
+    assert.dom.equal(
+      peopleContainer,
+      `
+      <section class="peopleContainer">
+        <gr-endpoint-decorator name="reply-reviewers">
+          <gr-endpoint-param name="change"> </gr-endpoint-param>
+          <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+          <div class="peopleList">
+            <div class="peopleListLabel">Reviewers</div>
+            <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+          </div>
+          <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+        </gr-endpoint-decorator>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+        </div>
+        <dialog
+          tabindex="-1"
+          id="reviewerConfirmationModal"
+        >
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName"> </span>
+            has
+            <span class="groupSize"> </span>
+            members.
+            <br />
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Yes
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              No
+            </gr-button>
+          </div>
+        </dialog>
+        <div class="privateVisiblityInfo">
+          <gr-icon icon="info">
+          </gr-icon>
+          <div>
+            Adding a reviewer/CC will make this private change visible to them
+          </div>
+        </div>
+      </section>
+    `
+    );
+  });
+
   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.
@@ -1343,6 +1457,70 @@
     }
   }
 
+  suite('reviewer toast for WIP changes', () => {
+    let fireStub: sinon.SinonStub;
+    setup(() => {
+      fireStub = sinon.stub(element, 'dispatchEvent');
+    });
+
+    test('toast not fired if change is already active', async () => {
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+      };
+      element.send(false, false);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isFalse(events.includes('show-alert'));
+    });
+
+    test('toast is not fired if change is WIP and becomes active', async () => {
+      const account = createAccountWithId(22);
+      element.reviewersList!.accounts = [];
+      element.reviewersList!.addAccountItem({account, count: 1});
+      element.reviewersList!.dispatchEvent(
+        new CustomEvent('account-added', {
+          detail: {account},
+        })
+      );
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+        work_in_progress: true,
+      };
+      element.send(false, true);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isFalse(events.includes('show-alert'));
+    });
+
+    test('toast is fired if change is WIP and becomes active and reviewer added', async () => {
+      const account = createAccountWithId(22);
+      element.reviewersList!.accounts = [];
+      element.reviewersList!.addAccountItem({account, count: 1});
+      element.reviewersList!.dispatchEvent(
+        new CustomEvent('account-added', {
+          detail: {account},
+        })
+      );
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+        work_in_progress: true,
+      };
+      element.send(false, false);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isTrue(events.includes('show-alert'));
+    });
+  });
+
   test('cc confirmation', async () => {
     testConfirmationDialog(true);
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index f7e3542..3fdb1c0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -323,10 +323,18 @@
 
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+      this.isExpandable = this.computeIsExpandable();
     }
   }
 
+  private computeIsExpandable() {
+    const hasSummary = !!this.result?.summary;
+    const hasMessage = !!this.result?.message;
+    const hasLinks = (this.result?.links ?? []).length > 0;
+    const hasPointers = (this.result?.codePointers ?? []).length > 0;
+    return hasSummary && (hasMessage || hasLinks || hasPointers);
+  }
+
   override focus() {
     if (this.nameEl) this.nameEl.focus();
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 934e958..113470c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -117,7 +117,6 @@
           aria-checked="false"
           aria-label="Expand result row"
           class="show-hide"
-          hidden=""
           role="switch"
           tabindex="0"
         >
@@ -262,6 +261,7 @@
             </h3>
             <gr-result-row
               class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+              isexpandable
             >
             </gr-result-row>
             <gr-result-row
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 64bfe91..2b7d1ff 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -528,6 +528,9 @@
     this.reporting.reportErrorDialog(message);
     this.errorDialog.text = message;
     this.errorDialog.showSignInButton = !!options && !!options.showSignInButton;
+    if (this.errorModal.hasAttribute('open')) {
+      this.errorModal.close();
+    }
     this.errorModal.showModal();
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 25173b4..45ba33b 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -61,7 +61,6 @@
           display: block;
           max-height: 100vh;
           min-width: 60vw;
-          overflow-y: auto;
         }
         main {
           display: flex;
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
new file mode 100644
index 0000000..e01bb34
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {serviceWorkerInstallerToken} from '../../../services/service-worker-installer';
+import {subscribe} from '../../lit/subscription-controller';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {navigationToken} from '../gr-navigation/gr-navigation';
+import {createSettingsUrl} from '../../../models/views/settings';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-notifications-prompt': GrNotificationsPrompt;
+  }
+}
+
+@customElement('gr-notifications-prompt')
+export class GrNotificationsPrompt extends LitElement {
+  @state() private hideNotificationsPrompt = false;
+
+  @state() private shouldShowPrompt = false;
+
+  private readonly serviceWorkerInstaller = resolve(
+    this,
+    serviceWorkerInstallerToken
+  );
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.serviceWorkerInstaller().shouldShowPrompt$,
+      shouldShowPrompt => {
+        this.shouldShowPrompt = !!shouldShowPrompt;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        #notificationsPrompt {
+          position: absolute;
+          right: 30px;
+          top: 100px;
+          z-index: 150; /* Less than gr-hovercard's, higher than rest */
+          display: flex;
+          background-color: var(--background-color-primary);
+          padding: var(--spacing-l);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-5);
+        }
+        h3 {
+          margin: 0;
+          padding: 0;
+        }
+        .icon {
+          flex: 0 0 30px;
+        }
+        .content {
+          width: 300px;
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        .message {
+          margin: var(--spacing-m) 0;
+        }
+        div.sectionIcon gr-icon {
+          position: relative;
+        }
+        b {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (this.hideNotificationsPrompt) return nothing;
+    if (!this.shouldShowPrompt) return nothing;
+    return html`<div id="notificationsPrompt" role="dialog">
+      <div class="icon">
+        <gr-icon icon="info"></gr-icon>
+      </div>
+      <div class="content">
+        <h3 class="heading-3">Missing your turn notifications?</h3>
+        <div class="message">
+          Get notified whenever it's your turn on a change. Gerrit needs
+          permission to send notifications. To turn on notifications, click
+          <b>Continue</b> and then <b>Allow</b> when prompted by your browser.
+        </div>
+        <div class="buttons">
+          <gr-button
+            primary=""
+            @click=${() => {
+              this.hideNotificationsPrompt = true;
+              this.serviceWorkerInstaller().requestPermission();
+            }}
+            >Continue</gr-button
+          >
+          <gr-button
+            @click=${() => {
+              this.hideNotificationsPrompt = true;
+              this.getNavigation().setUrl(createSettingsUrl());
+            }}
+            >Disable in settings</gr-button
+          >
+        </div>
+      </div>
+      <div class="icon">
+        <gr-button
+          @click=${() => {
+            this.hideNotificationsPrompt = true;
+          }}
+          link
+        >
+          <gr-icon icon="close"></gr-icon>
+        </gr-button>
+      </div>
+    </div>`;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
new file mode 100644
index 0000000..d06b405
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-notifications-prompt';
+import {GrNotificationsPrompt} from './gr-notifications-prompt';
+import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from '../../../services/service-worker-installer';
+import {waitUntilObserved} from '../../../test/test-utils';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-notifications-prompt tests', () => {
+  let element: GrNotificationsPrompt;
+  let serviceWorkerInstaller: ServiceWorkerInstaller;
+
+  setup(async () => {
+    sinon
+      .stub(window.navigator.serviceWorker, 'register')
+      .returns(Promise.resolve({} as ServiceWorkerRegistration));
+    const flagsService = getAppContext().flagsService;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    const userModel = testResolver(userModelToken);
+    const prefs = {
+      ...createDefaultPreferences(),
+      allow_browser_notifications: true,
+    };
+    userModel.setPreferences(prefs);
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    serviceWorkerInstaller = testResolver(serviceWorkerInstallerToken);
+    // Since we cannot stub Notification.permission, we stub shouldShowPrompt.
+    sinon.stub(serviceWorkerInstaller, 'shouldShowPrompt').returns(true);
+    element = await fixture(
+      html`<gr-notifications-prompt></gr-notifications-prompt>`
+    );
+    await waitUntilObserved(
+      serviceWorkerInstaller.shouldShowPrompt$,
+      shouldShowPrompt => shouldShowPrompt === true
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element, // cannot format with HTML because test will not pass.
+      `<div id="notificationsPrompt" role="dialog">
+        <div class="icon"><gr-icon icon="info"> </gr-icon></div>
+        <div class="content">
+          <h3 class="heading-3">Missing your turn notifications?</h3>
+          <div class="message">
+            Get notified whenever it's your turn on a change. Gerrit needs
+          permission to send notifications. To turn on notifications, click
+            <b> Continue </b> and then <b> Allow </b>
+            when prompted by your browser.
+          </div>
+          <div class="buttons">
+            <gr-button
+              aria-disabled="false"
+              primary=""
+              role="button"
+              tabindex="0"
+            >
+              Continue
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Disable in settings
+            </gr-button>
+          </div>
+        </div>
+        <div class="icon">
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+            <gr-icon icon="close"> </gr-icon>
+          </gr-button>
+        </div>
+      </div>`
+    );
+  });
+});
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 f547cc7..6caa000 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -22,6 +22,7 @@
   UrlEncodedCommentId,
   PARENT,
   PatchSetNumber,
+  BranchName,
 } from '../../../types/common';
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
@@ -141,6 +142,10 @@
   // Matches /admin/repos/<repo>,commands.
   REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
+  // For creating a change, and going directly into editing mode for one file.
+  REPO_EDIT_FILE:
+    /^\/admin\/repos\/edit\/repo\/(.+)\/branch\/(.+)\/file\/(.+)$/,
+
   REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
 
   // Matches /admin/repos/<repos>,access.
@@ -521,6 +526,20 @@
     this.redirect(url);
   }
 
+  private dispatchLocationChangeEvent() {
+    const detail: LocationChangeEventDetail = {
+      hash: window.location.hash,
+      pathname: window.location.pathname,
+    };
+    document.dispatchEvent(
+      new CustomEvent('location-change', {
+        detail,
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
   startRouter() {
     const base = getBaseUrl();
     if (base) {
@@ -568,17 +587,7 @@
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
       setTimeout(() => {
-        const detail: LocationChangeEventDetail = {
-          hash: window.location.hash,
-          pathname: window.location.pathname,
-        };
-        document.dispatchEvent(
-          new CustomEvent('location-change', {
-            detail,
-            composed: true,
-            bubbles: true,
-          })
-        );
+        this.dispatchLocationChangeEvent();
       }, 1);
       next();
     });
@@ -676,6 +685,13 @@
       true
     );
 
+    this.mapRoute(
+      RoutePattern.REPO_EDIT_FILE,
+      'handleRepoEditFileRoute',
+      ctx => this.handleRepoEditFileRoute(ctx),
+      true
+    );
+
     this.mapRoute(RoutePattern.REPO_GENERAL, 'handleRepoGeneralRoute', ctx =>
       this.handleRepoGeneralRoute(ctx)
     );
@@ -1151,6 +1167,22 @@
     this.reporting.setRepoName(repo);
   }
 
+  handleRepoEditFileRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const branch = ctx.params[1] as BranchName;
+    const path = ctx.params[2];
+    const state: RepoViewState = {
+      view: GerritView.REPO,
+      detail: RepoDetailView.COMMANDS,
+      repo,
+      createEdit: {branch, path},
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
+    this.reporting.setRepoName(repo);
+  }
+
   handleRepoGeneralRoute(ctx: PageContext) {
     const repo = ctx.params[0] as RepoName;
     const state: RepoViewState = {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 597c7c0..b8f68e6 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -139,6 +139,7 @@
       'handlePluginListOffsetRoute',
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
+      'handleRepoEditFileRoute',
       'handleSettingsLegacyRoute',
       'handleSettingsRoute',
     ];
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 5bcef11..b9c920a 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -19,6 +19,7 @@
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {createSearchUrl} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
@@ -98,7 +99,11 @@
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getSuggestedRepos(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedRepos(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(projects => {
         if (!projects) {
           return [];
@@ -128,7 +133,12 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedGroups(expression, undefined, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedGroups(
+        expression,
+        undefined,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(groups => {
         if (!groups) {
           return [];
@@ -158,7 +168,13 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedAccounts(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS,
+        /* canSee=*/ undefined,
+        /* filterActive=*/ undefined,
+        throwingErrorCallback
+      )
       .then(accounts => {
         if (!accounts) {
           return [];
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 36c9397..b68adf1 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
@@ -961,18 +961,18 @@
           )}
         `
       )}
-      <span class="separator"></span>
-      <div class="diffModeSelector ${diffModeSelectorClass}">
-        <span>Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          .saveOnChange=${this.loggedIn}
-          show-tooltip-below
-        ></gr-diff-mode-selector>
-      </div>
       ${when(
         this.loggedIn && this.prefs,
         () => html`
+          <span class="separator"></span>
+          <div class="diffModeSelector ${diffModeSelectorClass}">
+            <span>Diff view:</span>
+            <gr-diff-mode-selector
+              id="modeSelect"
+              .saveOnChange=${this.loggedIn}
+              show-tooltip-below
+            ></gr-diff-mode-selector>
+          </div>
           <span id="diffPrefsContainer">
             <span class="preferences desktop">
               <gr-tooltip-content
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 6cfefe5..d1d05b7 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -33,6 +33,7 @@
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {whenVisible} from '../../../utils/dom-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 @customElement('gr-edit-controls')
 export class GrEditControls extends LitElement {
@@ -514,7 +515,12 @@
     assertIsDefined(this.change, 'this.change');
     assertIsDefined(this.patchNum, 'this.patchNum');
     return this.restApiService
-      .queryChangeFiles(this.change._number, this.patchNum, input)
+      .queryChangeFiles(
+        this.change._number,
+        this.patchNum,
+        input,
+        throwingErrorCallback
+      )
       .then(res => {
         if (!res)
           throw new Error('Failed to retrieve files. Response not set.');
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 2a5bcf5..c00def6 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -28,6 +28,7 @@
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
+import './core/gr-notifications-prompt/gr-notifications-prompt';
 import {getBaseUrl} from '../utils/url-util';
 import {navigationToken} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
@@ -181,7 +182,7 @@
     this.addEventListener(EventType.DIALOG_CHANGE, e => {
       this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    this.addEventListener(EventType.LOCATION_CHANGE, () =>
+    document.addEventListener(EventType.LOCATION_CHANGE, () =>
       this.handleLocationChange()
     );
     this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
@@ -366,6 +367,7 @@
       </main>
       ${this.renderFooter()} ${this.renderKeyboardShortcutsDialog()}
       ${this.renderRegistrationDialog()}
+      <gr-notifications-prompt></gr-notifications-prompt>
       <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
       <gr-error-manager
         id="errorManager"
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 4d0939ba..645f94a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -45,8 +45,10 @@
 } from '../services/gr-reporting/gr-reporting_impl';
 import {html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators.js';
-import {ServiceWorkerInstaller} from '../services/service-worker-installer';
-import {userModelToken} from '../models/user/user-model';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from '../services/service-worker-installer';
 import {pluginLoaderToken} from './shared/gr-js-api-interface/gr-plugin-loader';
 
 const appContext = createAppContext();
@@ -106,13 +108,8 @@
 
     initGerrit(resolver(pluginLoaderToken));
 
-    // TODO(milutin): Move inside app dependencies.
     if (!this.serviceWorkerInstaller) {
-      this.serviceWorkerInstaller = new ServiceWorkerInstaller(
-        appContext.flagsService,
-        appContext.reportingService,
-        resolver(userModelToken)
-      );
+      this.serviceWorkerInstaller = resolver(serviceWorkerInstallerToken);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 730548c..05ee9ed 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -20,6 +20,26 @@
 import {resolve} from '../models/dependency';
 import {removeRequestDependencyListener} from '../test/common-test-setup';
 
+suite('gr-app callback tests', () => {
+  const handleLocationChangeSpy = sinon.spy(
+    GrAppElement.prototype,
+    <any>'handleLocationChange'
+  );
+  const dispatchLocationChangeEventSpy = sinon.spy(
+    GrRouter.prototype,
+    <any>'dispatchLocationChangeEvent'
+  );
+
+  setup(async () => {
+    await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
+  });
+
+  test("handleLocationChange in gr-app-element is called after dispatching 'location-change' event in gr-router", () => {
+    dispatchLocationChangeEventSpy();
+    assert.isTrue(handleLocationChangeSpy.calledOnce);
+  });
+});
+
 suite('gr-app tests', () => {
   let grApp: GrApp;
   const config = createServerInfo();
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 b589915..49f2284 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
@@ -11,6 +11,7 @@
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-account-info/gr-account-info';
 import '../gr-agreements-list/gr-agreements-list';
 import '../gr-edit-preferences/gr-edit-preferences';
@@ -880,12 +881,26 @@
   private renderBrowserNotifications() {
     if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
       return nothing;
-    if (!areNotificationsEnabled(this.account)) return nothing;
+    if (
+      !this.flagsService.isEnabled(
+        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+      ) &&
+      !areNotificationsEnabled(this.account)
+    )
+      return nothing;
     return html`
       <section id="allowBrowserNotificationsSection">
-        <label class="title" for="allowBrowserNotifications"
-          >Allow browser notifications</label
-        >
+        <div class="title">
+          <label for="allowBrowserNotifications"
+            >Allow browser notifications</label
+          >
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
         <span class="value">
           <input
             id="allowBrowserNotifications"
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 24a73b2..a5ef86d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -525,9 +525,17 @@
     assert.dom.equal(
       queryAndAssert(element, '#allowBrowserNotificationsSection'),
       /* HTML */ `<section id="allowBrowserNotificationsSection">
-        <label class="title" for="allowBrowserNotifications">
-          Allow browser notifications
-        </label>
+        <div class="title">
+          <label for="allowBrowserNotifications">
+            Allow browser notifications
+          </label>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"> </gr-icon>
+          </a>
+        </div>
         <span class="value">
           <input checked="" id="allowBrowserNotifications" type="checkbox" />
         </span>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 15ec512..2996e50 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -21,6 +21,7 @@
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
 import {PropertiesOfType} from '../../../utils/type-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
 
@@ -193,13 +194,15 @@
 
   // private but used in tests.
   getProjectSuggestions(input: string) {
-    return this.restApiService.getSuggestedRepos(input).then(response => {
-      const repos: AutocompleteSuggestion[] = [];
-      for (const [name, repo] of Object.entries(response ?? {})) {
-        repos.push({name, value: repo.id});
-      }
-      return repos;
-    });
+    return this.restApiService
+      .getSuggestedRepos(input, /* n=*/ undefined, throwingErrorCallback)
+      .then(response => {
+        const repos: AutocompleteSuggestion[] = [];
+        for (const [name, repo] of Object.entries(response ?? {})) {
+          repos.push({name, value: repo.id});
+        }
+        return repos;
+      });
   }
 
   private handleRemoveProject(project: ProjectWatchInfo) {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 4005bb3..91e601c 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -11,6 +11,7 @@
 import {FitController} from '../../lit/fit-controller';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
 import {repeat} from 'lit/directives/repeat.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ShortcutController} from '../../lit/shortcut-controller';
@@ -54,6 +55,12 @@
   @property({type: Boolean, reflect: true, attribute: 'is-hidden'})
   isHidden = true;
 
+  /** If specified a single non-interactable line is shown instead of
+   * suggestions.
+   */
+  @property({type: String})
+  errorMessage?: String;
+
   @property({type: Number})
   verticalOffset = 0;
 
@@ -110,6 +117,12 @@
         li.selected {
           background-color: var(--hover-background-color);
         }
+        li.query-error {
+          background-color: var(--disabled-background);
+          color: var(--error-foreground);
+          cursor: default;
+          white-space: pre-wrap;
+        }
         @media only screen and (max-height: 35em) {
           .dropdown-content {
             max-height: 80vh;
@@ -126,21 +139,25 @@
     ];
   }
 
+  private isSuggestionListInteractible() {
+    return !this.isHidden && !this.errorMessage;
+  }
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
-    this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
-    this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+    this.shortcuts.addLocal({key: Key.UP, allowRepeat: true}, () =>
+      this.cursorUp()
+    );
+    this.shortcuts.addLocal({key: Key.DOWN, allowRepeat: true}, () =>
+      this.cursorDown()
+    );
     this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
     this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
     this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-  }
-
   override disconnectedCallback() {
     this.cursor.unsetCursor();
     super.disconnectedCallback();
@@ -163,32 +180,46 @@
     }
   }
 
+  private renderError() {
+    return html`
+      <li
+        tabindex="-1"
+        aria-label="autocomplete query error"
+        class="query-error"
+      >
+        <span>${this.errorMessage}</span>
+        <span class="label">ERROR</span>
+      </li>
+    `;
+  }
+
   override render() {
     return html`
-      <div
-        class="dropdown-content"
-        slot="dropdown-content"
-        id="suggestions"
-        role="listbox"
-      >
+      <div class="dropdown-content" id="suggestions" role="listbox">
         <ul>
-          ${repeat(
-            this.suggestions,
-            (item, index) => html`
-              <li
-                data-index=${index}
-                data-value=${item.dataValue ?? ''}
-                tabindex="-1"
-                aria-label=${item.name ?? ''}
-                class="autocompleteOption"
-                role="option"
-                @click=${this.handleClickItem}
-              >
-                <span>${item.text}</span>
-                <span class="label ${this.computeLabelClass(item)}"
-                  >${item.label}</span
-                >
-              </li>
+          ${when(
+            this.errorMessage,
+            () => this.renderError(),
+            () => html`
+              ${repeat(
+                this.suggestions,
+                (item, index) => html`
+                  <li
+                    data-index=${index}
+                    data-value=${item.dataValue ?? ''}
+                    tabindex="-1"
+                    aria-label=${item.name ?? ''}
+                    class="autocompleteOption"
+                    role="option"
+                    @click=${this.handleClickItem}
+                  >
+                    <span>${item.text}</span>
+                    <span class="label ${this.computeLabelClass(item)}"
+                      >${item.label}</span
+                    >
+                  </li>
+                `
+              )}
             `
           )}
         </ul>
@@ -205,55 +236,54 @@
   }
 
   getCurrentText() {
-    return this.getCursorTarget()?.dataset['value'] || '';
+    if (!this.errorMessage) {
+      return this.getCursorTarget()?.dataset['value'] || '';
+    }
+    return '';
   }
 
   setPositionTarget(target: HTMLElement) {
-    this.fitController?.setPositionTarget(target);
-  }
-
-  private handleUp() {
-    if (!this.isHidden) this.cursorUp();
-  }
-
-  private handleDown() {
-    if (!this.isHidden) this.cursorDown();
+    this.fitController.setPositionTarget(target);
   }
 
   cursorDown() {
-    if (!this.isHidden) this.cursor.next();
+    if (this.isSuggestionListInteractible()) this.cursor.next();
   }
 
   cursorUp() {
-    if (!this.isHidden) this.cursor.previous();
+    if (this.isSuggestionListInteractible()) this.cursor.previous();
   }
 
   // private but used in tests
   handleTab() {
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'tab',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.isSuggestionListInteractible()) {
+      this.dispatchEvent(
+        new CustomEvent<ItemSelectedEvent>('item-selected', {
+          detail: {
+            trigger: 'tab',
+            selected: this.cursor.target,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
   }
 
   // private but used in tests
   handleEnter() {
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'enter',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.isSuggestionListInteractible()) {
+      this.dispatchEvent(
+        new CustomEvent<ItemSelectedEvent>('item-selected', {
+          detail: {
+            trigger: 'enter',
+            selected: this.cursor.target,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
   }
 
   private handleEscape() {
@@ -294,13 +324,13 @@
   computeCursorStopsAndRefit() {
     if (this.suggestions.length > 0) {
       this.cursor.stops = Array.from(
-        this.suggestionsDiv?.querySelectorAll('li') ?? []
+        this.suggestionsDiv?.querySelectorAll('li.autocompleteOption') ?? []
       );
       this.resetCursorIndex();
     } else {
       this.cursor.stops = [];
     }
-    this.fitController?.refit();
+    this.fitController.refit();
   }
 
   private setIndex() {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 641dd2d..54d054b 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -18,160 +18,233 @@
 import {Key} from '../../../utils/dom-util';
 
 suite('gr-autocomplete-dropdown', () => {
-  let element: GrAutocompleteDropdown;
+  suite('suggestion tests', () => {
+    let element: GrAutocompleteDropdown;
 
-  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+    const suggestionsEl = () => queryAndAssert(element, '#suggestions');
 
-  setup(async () => {
-    element = await fixture(
-      html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
-    );
-    element.open();
-    element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
-    ];
-    await waitEventLoop();
-  });
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.suggestions = [
+        {
+          dataValue: 'test value 1',
+          name: 'test name 1',
+          text: '1',
+          label: 'hi',
+        },
+        {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+      ];
+      await waitEventLoop();
+    });
 
-  teardown(() => {
-    element.close();
-  });
+    teardown(() => {
+      element.close();
+    });
 
-  test('renders', () => {
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <div
-          class="dropdown-content"
-          id="suggestions"
-          role="listbox"
-          slot="dropdown-content"
-        >
-          <ul>
-            <li
-              aria-label="test name 1"
-              class="autocompleteOption selected"
-              data-index="0"
-              data-value="test value 1"
-              role="option"
-              tabindex="-1"
-            >
-              <span> 1 </span>
-              <span class="label"> hi </span>
-            </li>
-            <li
-              aria-label="test name 2"
-              class="autocompleteOption"
-              data-index="1"
-              data-value="test value 2"
-              role="option"
-              tabindex="-1"
-            >
-              <span> 2 </span>
-              <span class="hide label"> </span>
-            </li>
-          </ul>
-        </div>
-      `
-    );
-  });
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="test name 1"
+                class="autocompleteOption selected"
+                data-index="0"
+                data-value="test value 1"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 1 </span>
+                <span class="label"> hi </span>
+              </li>
+              <li
+                aria-label="test name 2"
+                class="autocompleteOption"
+                data-index="1"
+                data-value="test value 2"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 2 </span>
+                <span class="hide label"> </span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
 
-  test('shows labels', () => {
-    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
-    assert.equal(els[0].innerText.trim(), '1\nhi');
-    assert.equal(els[1].innerText.trim(), '2');
-  });
+    test('shows labels', () => {
+      const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(els[0].innerText.trim(), '1\nhi');
+      assert.equal(els[1].innerText.trim(), '2');
+    });
 
-  test('escape key', async () => {
-    const closeSpy = sinon.spy(element, 'close');
-    pressKey(element, Key.ESC);
-    await waitEventLoop();
-    assert.isTrue(closeSpy.called);
-  });
+    test('escape key close suggestions', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
 
-  test('tab key', () => {
-    const handleTabSpy = sinon.spy(element, 'handleTab');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    pressKey(element, Key.TAB);
-    assert.isTrue(handleTabSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.isTrue(itemSelectedStub.called);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'tab',
-      selected: element.getCursorTarget(),
+    test('tab key', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.isTrue(itemSelectedStub.called);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tab',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('enter key', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'enter',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('down key', () => {
+      element.isHidden = true;
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      pressKey(element, 'ArrowDown');
+      assert.isTrue(nextSpy.called);
+      assert.equal(element.cursor.index, 1);
+    });
+
+    test('up key', () => {
+      element.isHidden = true;
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      assert.isFalse(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      element.cursor.setCursorAtIndex(1);
+      assert.equal(element.cursor.index, 1);
+      pressKey(element, 'ArrowUp');
+      assert.isTrue(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+    });
+
+    test('tapping selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      suggestionsEl().querySelectorAll('li')[1].click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: suggestionsEl().querySelectorAll('li')[1],
+      });
+    });
+
+    test('tapping child still selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
+        ?.lastElementChild;
+      assertIsDefined(lastElChild);
+      (lastElChild as HTMLSpanElement).click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+      });
+    });
+
+    test('updated suggestions resets cursor stops', async () => {
+      const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
+      element.suggestions = [];
+      await waitUntil(() => resetStopsSpy.called);
     });
   });
 
-  test('enter key', () => {
-    const handleEnterSpy = sinon.spy(element, 'handleEnter');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    pressKey(element, Key.ENTER);
-    assert.isTrue(handleEnterSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'enter',
-      selected: element.getCursorTarget(),
+  suite('error tests', () => {
+    let element: GrAutocompleteDropdown;
+
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.errorMessage = 'Failed query error';
+      await waitEventLoop();
     });
-  });
 
-  test('down key', () => {
-    element.isHidden = true;
-    const nextSpy = sinon.spy(element.cursor, 'next');
-    pressKey(element, 'ArrowDown');
-    assert.isFalse(nextSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    pressKey(element, 'ArrowDown');
-    assert.isTrue(nextSpy.called);
-    assert.equal(element.cursor.index, 1);
-  });
-
-  test('up key', () => {
-    element.isHidden = true;
-    const prevSpy = sinon.spy(element.cursor, 'previous');
-    pressKey(element, 'ArrowUp');
-    assert.isFalse(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    element.cursor.setCursorAtIndex(1);
-    assert.equal(element.cursor.index, 1);
-    pressKey(element, 'ArrowUp');
-    assert.isTrue(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-  });
-
-  test('tapping selects item', async () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-
-    suggestionsEl().querySelectorAll('li')[1].click();
-    await waitEventLoop();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: suggestionsEl().querySelectorAll('li')[1],
+    teardown(() => {
+      element.close();
     });
-  });
 
-  test('tapping child still selects item', async () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
-      ?.lastElementChild;
-    assertIsDefined(lastElChild);
-    (lastElChild as HTMLSpanElement).click();
-    await waitEventLoop();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+    test('renders error', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query error"
+                class="query-error"
+                tabindex="-1"
+              >
+                <span>Failed query error</span>
+                <span class="label">ERROR</span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
     });
-  });
 
-  test('updated suggestions resets cursor stops', async () => {
-    const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
-    element.suggestions = [];
-    await waitUntil(() => resetStopsSpy.called);
+    test('escape key close dropdown with error', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('tab key when error shown sends no event', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('enter key when error shown sends no event', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('up/down disabled when error', () => {
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.isFalse(prevSpy.called);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 34e67b9..f7f8ea5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -81,6 +81,9 @@
    * next to the "name" as label text. The "value" property will be emitted
    * if that suggestion is selected.
    *
+   * If query fails, the function should return rejected promise containing
+   * an Error. The "message" property will be shown in a dropdown instead of
+   * rendering suggestions.
    */
   @property({type: Object})
   query?: AutocompleteQuery = () => Promise.resolve([]);
@@ -169,6 +172,8 @@
 
   @state() suggestions: AutocompleteSuggestion[] = [];
 
+  @state() queryErrorMessage?: string;
+
   @state() index: number | null = null;
 
   // Enabled to suppress showing/updating suggestions when changing properties
@@ -269,7 +274,10 @@
     ) {
       this.updateSuggestions();
     }
-    if (changedProperties.has('suggestions')) {
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('queryErrorMessage')
+    ) {
       this.updateDropdownVisibility();
     }
     if (changedProperties.has('text')) {
@@ -317,6 +325,7 @@
         @item-selected=${this.handleItemSelect}
         @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
         .suggestions=${this.suggestions}
+        .errorMessage=${this.queryErrorMessage}
         role="listbox"
         .index=${this.index}
       >
@@ -424,6 +433,7 @@
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
     this.suggestions = [];
+    this.queryErrorMessage = undefined;
 
     // TODO(taoalpha): Also skip if text has not changed
 
@@ -446,19 +456,28 @@
     }
 
     const update = () => {
-      query(this.text).then(suggestions => {
-        if (this.text !== this.text) {
-          // Late response.
-          return;
-        }
-        for (const suggestion of suggestions) {
-          suggestion.text = suggestion?.name ?? '';
-        }
-        this.suggestions = suggestions;
-        if (this.index === -1) {
+      query(this.text)
+        .then(suggestions => {
+          if (this.text !== this.text) {
+            // Late response.
+            return;
+          }
+          for (const suggestion of suggestions) {
+            suggestion.text = suggestion?.name ?? '';
+          }
+          this.suggestions = suggestions;
+          if (this.index === -1) {
+            this.value = '';
+          }
+        })
+        .catch(e => {
           this.value = '';
-        }
-      });
+          if (typeof e === 'string') {
+            this.queryErrorMessage = e;
+          } else if (e instanceof Error) {
+            this.queryErrorMessage = e.message;
+          }
+        });
     };
 
     if (this.noDebounce) {
@@ -479,7 +498,10 @@
   }
 
   updateDropdownVisibility() {
-    if (this.suggestions.length > 0 && this.focused) {
+    if (
+      (this.suggestions.length > 0 || this.queryErrorMessage) &&
+      this.focused
+    ) {
       this.suggestionsDropdown?.open();
       return;
     }
@@ -526,6 +548,7 @@
         }
         if (this.suggestions.length > 0) {
           // If suggestions are shown, act as if the keypress is in dropdown.
+          // suggestions length is 0 if error is shown.
           this.handleItemSelectEnter(e);
         } else {
           e.preventDefault();
@@ -553,8 +576,9 @@
   }
 
   cancel() {
-    if (this.suggestions.length) {
+    if (this.suggestions.length || this.queryErrorMessage) {
       this.suggestions = [];
+      this.queryErrorMessage = undefined;
       this.requestUpdate();
     } else {
       fireEvent(this, 'cancel');
@@ -562,8 +586,11 @@
   }
 
   handleInputCommit(_tabComplete?: boolean) {
-    // Nothing to do if the dropdown is not open.
-    if (!this.allowNonSuggestedValues && this.suggestionsDropdown?.isHidden) {
+    // Nothing to do if no suggestions.
+    if (
+      !this.allowNonSuggestedValues &&
+      (this.suggestionsDropdown?.isHidden || this.queryErrorMessage)
+    ) {
       return;
     }
 
@@ -641,6 +668,7 @@
     }
 
     this.suggestions = [];
+    this.queryErrorMessage = undefined;
     // we need willUpdate to send text-changed event before we can send the
     // 'commit' event
     await this.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index d991180..e593c0d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -6,7 +6,12 @@
 import '../../../test/common-test-setup';
 import './gr-autocomplete';
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
-import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {
+  assertFails,
+  pressKey,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -109,6 +114,46 @@
     );
   });
 
+  test('renders with error', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.reject(new Error(`${input} not allowed`))
+    );
+    element.query = queryStub;
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown id="suggestions" role="listbox">
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+    assert.equal(element.suggestionsDropdown?.errorMessage, 'blah not allowed');
+  });
+
   test('cursor starts on suggestions', async () => {
     const queryStub = sinon.spy((input: string) =>
       Promise.resolve([
@@ -181,6 +226,39 @@
     });
   });
 
+  test('esc key behavior on error', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (_: string) => (promise = Promise.reject(new Error('Test error')))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+
+    return assertFails(promise).then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+      assert.equal(element.queryErrorMessage, 'Test error');
+
+      pressKey(inputEl(), Key.ESC);
+      await waitUntil(() => suggestionsEl().isHidden);
+
+      assert.isFalse(cancelHandler.called);
+      assert.isUndefined(element.queryErrorMessage);
+
+      pressKey(inputEl(), Key.ESC);
+      await element.updateComplete;
+
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
   test('emits commit and handles cursor movement', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
@@ -403,6 +481,25 @@
     });
   });
 
+  test('error should not carry over', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns((promise = Promise.reject(new Error('Test error'))));
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    await element.updateComplete;
+    return assertFails(promise).then(async () => {
+      await waitUntil(() => element.queryErrorMessage === 'Test error');
+      element.text = '';
+      element.threshold = 0;
+      element.noDebounce = false;
+      await element.updateComplete;
+      assert.isUndefined(element.queryErrorMessage);
+    });
+  });
+
   test('multi completes only the last part of the query', async () => {
     let promise;
     const queryStub = sinon
@@ -504,7 +601,7 @@
 
   test(
     'handleInputCommit with autocomplete hidden does nothing without' +
-      'without allowNonSuggestedValues',
+      ' allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       suggestionsEl().isHidden = true;
@@ -514,6 +611,17 @@
   );
 
   test(
+    'handleInputCommit with query error does nothing without' +
+      ' allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.queryErrorMessage = 'Error';
+      element.handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
     'handleInputCommit with autocomplete hidden with' +
       'allowNonSuggestedValues',
     () => {
@@ -525,6 +633,17 @@
     }
   );
 
+  test(
+    'handleInputCommit with query error with' + 'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      element.queryErrorMessage = 'Error';
+      element.handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
   test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
@@ -587,6 +706,21 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryErrorMessage);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('enter in input does not re-render error', async () => {
+      element.allowNonSuggestedValues = true;
+      element.queryErrorMessage = 'Error message';
+
+      pressKey(inputEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryErrorMessage);
       assert.isTrue(suggestionsEl().isHidden);
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index 4fa716b..1bfb55b 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -7,7 +7,10 @@
 import {AccountInfo} from '../../../types/common';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
-import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {
+  uniqueAccountId,
+  uniqueDefinedAvatar,
+} from '../../../utils/account-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {subscribe} from '../../lit/subscription-controller';
@@ -39,6 +42,15 @@
   imageSize = 16;
 
   /**
+   * In gr-app, gr-account-chip is in charge of loading a full account, so
+   * avatars will be set. However, code-owners will create gr-avatars with a
+   * bare account-id. To enable fetching of those avatars, a flag is added to
+   * gr-avatar that will disregard the absence of avatar urls.
+   */
+  @property({type: Boolean})
+  forceFetch = false;
+
+  /**
    * Reflects plugins.has_avatars value of server configuration.
    */
   @state() private hasAvatars = false;
@@ -74,9 +86,11 @@
   }
 
   override render() {
-    const uniqueAvatarAccounts = this.accounts
-      .filter(account => !!account?.avatars?.[0]?.url)
-      .filter(uniqueDefinedAvatar);
+    const uniqueAvatarAccounts = this.forceFetch
+      ? this.accounts.filter(uniqueAccountId)
+      : this.accounts
+          .filter(account => !!account?.avatars?.[0]?.url)
+          .filter(uniqueDefinedAvatar);
     if (
       !this.hasAvatars ||
       uniqueAvatarAccounts.length === 0 ||
@@ -86,7 +100,11 @@
     }
     return uniqueAvatarAccounts.map(
       account =>
-        html`<gr-avatar .account=${account} .imageSize=${this.imageSize}>
+        html`<gr-avatar
+          .forceFetch=${this.forceFetch}
+          .account=${account}
+          .imageSize=${this.imageSize}
+        >
         </gr-avatar>`
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 1ea2a64..8cfe2d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -25,6 +25,14 @@
 
   @state() private hasAvatars = false;
 
+  // In gr-app, gr-account-chip is in charge of loading a full account, so
+  // avatars will be set. However, code-owners will create gr-avatars with a
+  // bare account-id. To enable fetching of those avatars, a flag is added to
+  // gr-avatar that will disregard the absence of avatar urls.
+
+  @property({type: Boolean})
+  forceFetch = false;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -90,7 +98,7 @@
     const avatars = account.avatars || [];
     // if there is no avatar url in account, there is no avatar set on server,
     // and request /avatar?s will be 404.
-    if (avatars.length === 0) {
+    if (avatars.length === 0 && !this.forceFetch) {
       return '';
     }
     for (let i = 0; i < avatars.length; i++) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index dbf75f4..04d5923 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -31,6 +31,9 @@
    * @event cancel
    */
 
+  @query('#cancel')
+  cancelButton?: GrButton;
+
   @query('#confirm')
   confirmButton?: GrButton;
 
@@ -102,6 +105,7 @@
           display: flex;
           flex-shrink: 0;
           padding-top: var(--spacing-xl);
+          align-items: center;
         }
         .flex-space {
           flex-grow: 1;
@@ -221,6 +225,10 @@
   }
 
   resetFocus() {
-    this.confirmButton!.focus();
+    if (this.disabled && this.cancelLabel) {
+      this.cancelButton!.focus();
+    } else {
+      this.confirmButton!.focus();
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index 8fa351b..6d2fe20 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -21,6 +21,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -121,7 +122,13 @@
       input = input.substring(REF_PREFIX.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.repo,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(res => this.branchResponseToSuggestions(res));
   }
 
@@ -143,7 +150,12 @@
   // private but used in test
   getRepoSuggestions(input: string) {
     return this.restApiService
-      .getRepos(input, SUGGESTIONS_LIMIT)
+      .getRepos(
+        input,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(res => this.repoResponseToSuggestions(res));
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 1fbe5b2..2de5b5f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -183,6 +183,35 @@
   [name: string]: string[] | string | number | boolean | undefined | null;
 };
 
+/**
+ * Error callback that throws an error.
+ *
+ * Pass into REST API methods as errFn to make the returned Promises reject on
+ * error.
+ *
+ * If error is provided, it's thrown.
+ * Otherwise if response with error is provided the promise that will throw an
+ * error is returned.
+ */
+export function throwingErrorCallback(
+  response?: Response | null,
+  err?: Error
+): void | Promise<void> {
+  if (err) throw err;
+  if (!response) return;
+
+  return response.text().then(errorText => {
+    let message = `Error ${response.status}`;
+    if (response.statusText) {
+      message += ` (${response.statusText})`;
+    }
+    if (errorText) {
+      message += `: ${errorText}`;
+    }
+    throw new Error(message);
+  });
+}
+
 interface SendRequestBase {
   method: HttpMethod | undefined;
   body?: RequestPayload;
@@ -361,27 +390,26 @@
    *
    * @param noAcceptHeader - don't add default accept json header
    */
-  fetchJSON(
+  async fetchJSON(
     req: FetchJSONRequest,
     noAcceptHeader?: boolean
   ): Promise<ParsedJSON | undefined> {
     if (!noAcceptHeader) {
       req = this.addAcceptJsonHeader(req);
     }
-    return this.fetchRawJSON(req).then(response => {
-      if (!response) {
+    const response = await this.fetchRawJSON(req);
+    if (!response) {
+      return;
+    }
+    if (!response.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, response);
         return;
       }
-      if (!response.ok) {
-        if (req.errFn) {
-          req.errFn.call(null, response);
-          return;
-        }
-        fireServerError(response, req);
-        return;
-      }
-      return this.getResponseObject(response);
-    });
+      fireServerError(response, req);
+      return;
+    }
+    return this.getResponseObject(response);
   }
 
   urlWithParams(url: string, fetchParams?: FetchParams): string {
@@ -474,7 +502,7 @@
    *     (i.e. no exception and response.ok is true). If response fails then
    *     promise resolves either to void if errFn is set or rejects if errFn
    *     is not set   */
-  send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
+  async send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
     const options: AuthRequestInit = {method: req.method};
     if (req.body) {
       options.headers = new Headers();
@@ -499,38 +527,30 @@
       fetchOptions: options,
       anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
     };
-    const xhr = this.fetch(fetchReq)
-      .catch(err => {
-        fireNetworkError(err);
-        if (req.errFn) {
-          req.errFn.call(undefined, null, err);
-          return;
-        } else {
-          throw err;
-        }
-      })
-      .then(response => {
-        if (response && !response.ok) {
-          if (req.errFn) {
-            req.errFn.call(undefined, response);
-            return;
-          }
-          fireServerError(response, fetchReq);
-        }
-        return response;
-      });
+    let xhr;
+    try {
+      xhr = await this.fetch(fetchReq);
+    } catch (err) {
+      fireNetworkError(err as Error);
+      if (req.errFn) {
+        req.errFn.call(undefined, null, err as Error);
+        xhr = undefined;
+      } else {
+        throw err;
+      }
+    }
+    if (xhr && !xhr.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, xhr);
+      } else {
+        fireServerError(xhr, fetchReq);
+      }
+    }
 
     if (req.parseResponse) {
-      // TODO(TS): remove as Response and fix error.
-      // Javascript code allows returning of a Response object from errFn.
-      // This can be a mistake and we should add check here or it can be used
-      // somewhere - in this case we should fix it carefully (define
-      // different type of callback if parseResponse is true, etc...).
-      return xhr.then(res => this.getResponseObject(res as Response));
+      xhr = xhr && this.getResponseObject(xhr);
     }
-    // The actual xhr type is Promise<Response|undefined|void> because of the
-    // catch callback
-    return xhr as Promise<Response | undefined>;
+    return xhr;
   }
 
   invalidateFetchPromisesPrefix(prefix: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 1234b59..9f0319e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -9,7 +9,7 @@
   FetchPromisesCache,
   GrRestApiHelper,
 } from './gr-rest-api-helper';
-import {waitEventLoop} from '../../../../test/test-utils';
+import {assertFails, waitEventLoop} from '../../../../test/test-utils';
 import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
 import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
 import {ParsedJSON} from '../../../../types/common';
@@ -229,6 +229,79 @@
     assert.isTrue(cancelCalled);
   });
 
+  suite('throwing in errFn', () => {
+    function throwInPromise(response?: Response | null, _?: Error) {
+      return response?.text().then(text => {
+        throw new Error(text);
+      });
+    }
+
+    function throwImmediately(_1?: Response | null, _2?: Error) {
+      throw new Error('Error Callback error');
+    }
+
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          status: 400,
+          ok: false,
+          text() {
+            return Promise.resolve('Nope');
+          },
+        })
+      );
+    });
+
+    test('errFn with Promise throw cause send to reject on error', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn: throwInPromise,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Nope');
+    });
+
+    test('errFn with Promise throw cause fetchJSON to reject on error', async () => {
+      const promise = helper.fetchJSON({
+        url: '/dummy/url',
+        errFn: throwInPromise,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Nope');
+    });
+
+    test('errFn with immediate throw cause send to reject on error', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn: throwImmediately,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Error Callback error');
+    });
+
+    test('errFn with immediate Promise cause fetchJSON to reject on error', async () => {
+      const promise = helper.fetchJSON({
+        url: '/dummy/url',
+        errFn: throwImmediately,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Error Callback error');
+    });
+  });
+
   suite('429 errors', () => {
     setup(() => {
       authFetchStub.returns(
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 169cd59..dd08544 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -234,9 +234,7 @@
       hiddenText in order to correctly position the dropdown. After being moved,
       it is set as the positionTarget for the emojiSuggestions dropdown. -->
       <span id="caratSpan"></span>
-      ${this.renderEmojiDropdown()}
-      ${this.renderMentionsDropdown()}
-      </gr-autocomplete-dropdown>
+      ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
       <iron-autogrow-textarea
         id="textarea"
         class=${classMap({noBorder: this.hideBorder})}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
index d1478b3..79c40de 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -11,6 +11,7 @@
 import {diffClasses} from '../gr-diff/gr-diff-utils';
 import {getShowConfig} from './gr-context-controls';
 import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
 
 @customElement('gr-context-controls-section')
 export class GrContextControlsSection extends LitElement {
@@ -20,8 +21,6 @@
   /** Should context controls be rendered for expanding below the section? */
   @property({type: Boolean}) showBelow = false;
 
-  @property({type: Object}) viewMode = DiffViewMode.SIDE_BY_SIDE;
-
   /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
   @property({type: Object})
   group?: GrDiffGroup;
@@ -54,9 +53,10 @@
   private renderPaddingRow(whereClass: 'above' | 'below') {
     if (!this.showAbove && whereClass === 'above') return;
     if (!this.showBelow && whereClass === 'below') return;
-    const sideBySide = this.viewMode === DiffViewMode.SIDE_BY_SIDE;
-    const modeClass = sideBySide ? 'side-by-side' : 'unified';
-    const type = sideBySide ? GrDiffGroupType.CONTEXT_CONTROL : undefined;
+    const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
+    const type = this.isSideBySide()
+      ? GrDiffGroupType.CONTEXT_CONTROL
+      : undefined;
     return html`
       <tr
         class=${diffClasses('contextBackground', modeClass, whereClass)}
@@ -65,22 +65,41 @@
       >
         <td class=${diffClasses('blame')} data-line-number="0"></td>
         <td class=${diffClasses('contextLineNum')}></td>
-        ${sideBySide ? html`<td class=${diffClasses()}></td>` : ''}
+        ${when(
+          this.isSideBySide(),
+          () => html`
+            <td class=${diffClasses('sign')}></td>
+            <td class=${diffClasses()}></td>
+          `
+        )}
         <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
         <td class=${diffClasses()}></td>
       </tr>
     `;
   }
 
+  private isSideBySide() {
+    return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+  }
+
   private createContextControlRow() {
-    const sideBySide = this.viewMode === DiffViewMode.SIDE_BY_SIDE;
+    // Note that <td> table cells that have `display: none` don't count!
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
     const showConfig = getShowConfig(this.showAbove, this.showBelow);
     return html`
       <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
         <td class=${diffClasses('blame')} data-line-number="0"></td>
-        ${sideBySide ? html`<td class=${diffClasses()}></td>` : ''}
-        <td class=${diffClasses('dividerCell')} colspan="3">
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses()}></td>`
+        )}
+        <td class=${diffClasses('dividerCell')} colspan=${colspan}>
           <gr-context-controls
+            class=${diffClasses()}
             .diff=${this.diff}
             .renderPreferences=${this.renderPrefs}
             .group=${this.group}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
index aa29ac3..6a557fc 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -35,15 +35,18 @@
             >
               <td class="blame gr-diff" data-line-number="0"></td>
               <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
               <td class="gr-diff"></td>
               <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
               <td class="gr-diff"></td>
             </tr>
             <tr class="dividerRow gr-diff show-both">
               <td class="blame gr-diff" data-line-number="0"></td>
               <td class="gr-diff"></td>
               <td class="dividerCell gr-diff" colspan="3">
-                <gr-context-controls showconfig="both"> </gr-context-controls>
+                <gr-context-controls class="gr-diff" showconfig="both">
+                </gr-context-controls>
               </td>
             </tr>
             <tr
@@ -53,8 +56,10 @@
             >
               <td class="blame gr-diff" data-line-number="0"></td>
               <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
               <td class="gr-diff"></td>
               <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
               <td class="gr-diff"></td>
             </tr>
           </tbody>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index c095ffb..5267f307 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -5,9 +5,12 @@
  */
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {queryAndAssert} from '../../../utils/common-util';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+import {queryAndAssert} from '../../../utils/common-util';
+import {html, render} from 'lit';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
   constructor(
@@ -18,12 +21,20 @@
     super(diff, prefs, outputEl);
   }
 
-  override buildSectionElement(): HTMLElement {
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const section = createElementDiff('tbody', 'binary-diff');
+    // Do not create a diff row for 'LOST'.
+    if (group.lines[0].beforeNumber !== 'FILE') return section;
+
     const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
     const fileRow = this.createRow(line);
-    const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
-    contentTd.textContent = ' Difference in binary files';
+    const contentTd = queryAndAssert<HTMLTableCellElement>(
+      fileRow,
+      'td.both.file'
+    )!;
+    const div = document.createElement('div');
+    render(html`<span>Difference in binary files</span>`, div);
+    contentTd.insertBefore(div, contentTd.firstChild);
     section.appendChild(fileRow);
     return section;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 6cd3cb0..12d7ec2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -7,7 +7,12 @@
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import './gr-diff-builder-side-by-side';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
+import {
+  DiffBuilder,
+  ImageDiffBuilder,
+  DiffContextExpandedEventDetail,
+  isImageDiffBuilder,
+} from './gr-diff-builder';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
@@ -114,7 +119,7 @@
   layers: DiffLayer[] = [];
 
   // visible for testing
-  builder?: DiffBuilder;
+  builder?: DiffBuilder | ImageDiffBuilder;
 
   /**
    * All layers, both from the outside and the default ones. See `layers` for
@@ -206,8 +211,8 @@
     return (
       this.cancelableRenderPromise
         .then(async () => {
-          if (this.isImageDiff) {
-            (this.builder as GrDiffBuilderImage).renderDiff();
+          if (isImageDiffBuilder(this.builder)) {
+            this.builder.renderImageDiff();
           }
           await this.untilGroupsRendered();
           this.fireDiffEvent('render-content');
@@ -428,6 +433,7 @@
     }
 
     let builder = null;
+    const useLit = this.renderPrefs?.use_lit_components ?? false;
     if (this.isImageDiff) {
       builder = new GrDiffBuilderImage(
         this.diff,
@@ -442,7 +448,10 @@
       // If the diff is binary, but not an image.
       return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
     } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      const useLit = this.renderPrefs?.use_lit_components;
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.SIDE_BY_SIDE,
+      };
       if (useLit) {
         builder = new GrDiffBuilderLit(
           this.diff,
@@ -461,13 +470,27 @@
         );
       }
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
-      builder = new GrDiffBuilderUnified(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.layersInternal,
-        this.renderPrefs
-      );
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      if (useLit) {
+        builder = new GrDiffBuilderLit(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      } else {
+        builder = new GrDiffBuilderUnified(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      }
     }
     if (!builder) {
       throw Error(`Unsupported diff view mode: ${this.viewMode}`);
@@ -518,7 +541,7 @@
           // If endIndex isn't present, continue to the end of the line.
           const endIndex =
             highlight.endIndex === undefined
-              ? line.text.length
+              ? GrAnnotation.getStringLength(line.text)
               : highlight.endIndex;
 
           GrAnnotation.annotateElement(
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index 5eabc59..0f02d71 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -271,10 +271,11 @@
 
       const str0 = slice(str, 0, 6);
       const str1 = slice(str, 6);
+      const numHighlightedChars = GrAnnotation.getStringLength(str1);
 
       layer.annotate(el, lineNumberEl, l, Side.LEFT);
 
-      assert.isTrue(annotateElementSpy.called);
+      assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
       assert.equal(el.childNodes.length, 2);
 
       assert.instanceOf(el.childNodes[0], Text);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 75ee088..0ea904a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -3,228 +3,265 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrEndpointParam} from '../../../elements/plugins/gr-endpoint-param/gr-endpoint-param';
-import {RenderPreferences} from '../../../api/diff';
+import {RenderPreferences, Side} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
-import {GrImageViewer} from '../gr-diff-image-viewer/gr-image-viewer';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {ImageDiffBuilder} from './gr-diff-builder';
+import {html, LitElement, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
 const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
-export class GrDiffBuilderImage extends GrDiffBuilderSideBySide {
+export class GrDiffBuilderImage
+  extends GrDiffBuilderSideBySide
+  implements ImageDiffBuilder
+{
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    private readonly _baseImage: ImageInfo | null,
-    private readonly _revisionImage: ImageInfo | null,
+    private readonly baseImage: ImageInfo | null,
+    private readonly revisionImage: ImageInfo | null,
     renderPrefs?: RenderPreferences,
-    private readonly _useNewImageDiffUi: boolean = false
+    private readonly useNewImageDiffUi: boolean = false
   ) {
     super(diff, prefs, outputEl, [], renderPrefs);
   }
 
-  public renderDiff() {
-    const section = createElementDiff('tbody', 'image-diff');
-
-    if (this._useNewImageDiffUi) {
-      this._emitImageViewer(section);
-
-      this.outputEl.appendChild(section);
-    } else {
-      this._emitImagePair(section);
-      this._emitImageLabels(section);
-
-      this.outputEl.appendChild(section);
-      this.outputEl.appendChild(this._createEndpoint());
-    }
+  public renderImageDiff() {
+    const imageDiff = this.useNewImageDiffUi
+      ? this.createImageDiffNew()
+      : this.createImageDiffOld();
+    this.outputEl.appendChild(imageDiff);
   }
 
-  private _createEndpoint() {
-    const tbody = createElementDiff('tbody');
-    const tr = createElementDiff('tr');
-    const td = createElementDiff('td');
-
-    // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
-    // column limit.
-    td.setAttribute('colspan', '4');
-    const endpointDomApi = createElementDiff('gr-endpoint-decorator');
-    endpointDomApi.setAttribute('name', 'image-diff');
-    endpointDomApi.appendChild(
-      this._createEndpointParam('baseImage', this._baseImage)
-    );
-    endpointDomApi.appendChild(
-      this._createEndpointParam('revisionImage', this._revisionImage)
-    );
-    td.appendChild(endpointDomApi);
-    tr.appendChild(td);
-    tbody.appendChild(tr);
-    return tbody;
+  private createImageDiffNew() {
+    const imageDiff = document.createElement('gr-diff-image-new');
+    imageDiff.automaticBlink = this.autoBlink();
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
   }
 
-  private _createEndpointParam(name: string, value: ImageInfo | null) {
-    const endpointParam = createElementDiff(
-      'gr-endpoint-param'
-    ) as GrEndpointParam;
-    endpointParam.name = name;
-    endpointParam.value = value;
-    return endpointParam;
+  private createImageDiffOld() {
+    const imageDiff = document.createElement('gr-diff-image-old');
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
   }
 
-  private _emitImageViewer(section: HTMLElement) {
-    const tr = createElementDiff('tr');
-    const td = createElementDiff('td');
-    // TODO(hermannloose): Support blame for image diffs, see above.
-    td.setAttribute('colspan', '4');
-    const imageViewer = createElementDiff('gr-image-viewer') as GrImageViewer;
-
-    imageViewer.baseUrl = this._getImageSrc(this._baseImage);
-    imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
-    imageViewer.automaticBlink =
-      !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
-
-    td.appendChild(imageViewer);
-    tr.appendChild(td);
-    section.appendChild(tr);
-  }
-
-  private _getImageSrc(image: ImageInfo | null): string {
-    return image && IMAGE_MIME_PATTERN.test(image.type)
-      ? `data:${image.type};base64,${image.body}`
-      : '';
-  }
-
-  private _emitImagePair(section: HTMLElement) {
-    const tr = createElementDiff('tr');
-
-    tr.appendChild(createElementDiff('td', 'left lineNum blank'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
-
-    tr.appendChild(createElementDiff('td', 'right lineNum blank'));
-    tr.appendChild(
-      this._createImageCell(this._revisionImage, 'right', section)
-    );
-
-    section.appendChild(tr);
-  }
-
-  private _createImageCell(
-    image: ImageInfo | null,
-    className: string,
-    section: HTMLElement
-  ) {
-    const td = createElementDiff('td', className);
-    const src = this._getImageSrc(image);
-    if (image && src) {
-      const imageEl = createElementDiff('img') as HTMLImageElement;
-      imageEl.onload = () => {
-        image._height = imageEl.naturalHeight;
-        image._width = imageEl.naturalWidth;
-        this._updateImageLabel(section, className, image);
-      };
-      imageEl.addEventListener('error', (e: Event) => {
-        imageEl.remove();
-        td.textContent = '[Image failed to load] ' + e.type;
-      });
-      imageEl.setAttribute('src', src);
-      td.appendChild(imageEl);
-    }
-    return td;
-  }
-
-  private _updateImageLabel(
-    section: HTMLElement,
-    className: string,
-    image: ImageInfo
-  ) {
-    const label = section.querySelector(
-      '.' + className + ' span.label'
-    ) as HTMLElement;
-    this._setLabelText(label, image);
-  }
-
-  private _setLabelText(label: HTMLElement, image: ImageInfo | null) {
-    label.textContent = getImageLabel(image);
-  }
-
-  private _emitImageLabels(section: HTMLElement) {
-    const tr = createElementDiff('tr');
-
-    let addNamesInLabel = false;
-
-    if (
-      this._baseImage &&
-      this._revisionImage &&
-      this._baseImage._name !== this._revisionImage._name
-    ) {
-      addNamesInLabel = true;
-    }
-
-    tr.appendChild(createElementDiff('td', 'left lineNum blank'));
-    let td = createElementDiff('td', 'left');
-    let label = createElementDiff('label');
-    let nameSpan;
-    let labelSpan = createElementDiff('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = createElementDiff('span', 'name');
-      nameSpan.textContent = this._baseImage?._name ?? '';
-      label.appendChild(nameSpan);
-      label.appendChild(createElementDiff('br'));
-    }
-
-    this._setLabelText(labelSpan, this._baseImage);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    tr.appendChild(createElementDiff('td', 'right lineNum blank'));
-    td = createElementDiff('td', 'right');
-    label = createElementDiff('label');
-    labelSpan = createElementDiff('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = createElementDiff('span', 'name');
-      nameSpan.textContent = this._revisionImage?._name ?? '';
-      label.appendChild(nameSpan);
-      label.appendChild(createElementDiff('br'));
-    }
-
-    this._setLabelText(labelSpan, this._revisionImage);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    section.appendChild(tr);
+  private autoBlink(): boolean {
+    return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
   }
 
   override updateRenderPrefs(renderPrefs: RenderPreferences) {
-    const imageViewer = this.outputEl.querySelector(
-      'gr-image-viewer'
-    ) as GrImageViewer;
-    if (this._useNewImageDiffUi && imageViewer) {
-      imageViewer.automaticBlink =
-        !!renderPrefs?.image_diff_prefs?.automatic_blink;
-    }
+    this.renderPrefs = renderPrefs;
+
+    // We have to update `imageDiff.automaticBlink` manually, because `this` is
+    // not a LitElement.
+    const imageDiff = this.outputEl.querySelector(
+      'gr-diff-image-new'
+    ) as GrDiffImageNew;
+    if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
   }
 }
 
-function getImageLabel(image: ImageInfo | null) {
-  if (image) {
-    const type = image.type ?? image._expectedType;
-    if (image._width && image._height) {
-      return `${image._width}×${image._height} ${type}`;
-    } else {
-      return type;
-    }
+@customElement('gr-diff-image-new')
+class GrDiffImageNew extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @property() automaticBlink = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
   }
-  return 'No image';
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-image-viewer
+              class="gr-diff"
+              .baseUrl=${imageSrc(this.baseImage)}
+              .revisionUrl=${imageSrc(this.revisionImage)}
+              .automaticBlink=${this.automaticBlink}
+            >
+            </gr-image-viewer>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+}
+
+@customElement('gr-diff-image-old')
+class GrDiffImageOld extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @query('img.left') baseImageEl?: HTMLImageElement;
+
+  @query('img.right') revisionImageEl?: HTMLImageElement;
+
+  @state() baseError?: string;
+
+  @state() revisionError?: string;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        ${this.renderImagePairRow()} ${this.renderImageLabelRow()}
+      </tbody>
+      ${this.renderEndpoint()}
+    `;
+  }
+
+  private renderEndpoint() {
+    return html`
+      <tbody class="gr-diff endpoint">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-endpoint-decorator class="gr-diff" name="image-diff">
+              ${this.renderEndpointParam('baseImage', this.baseImage)}
+              ${this.renderEndpointParam('revisionImage', this.revisionImage)}
+            </gr-endpoint-decorator>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderEndpointParam(name: string, value: unknown) {
+    if (!value) return nothing;
+    return html`
+      <gr-endpoint-param class="gr-diff" name=${name} .value=${value}>
+      </gr-endpoint-param>
+    `;
+  }
+
+  private renderImagePairRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">${this.renderImage(Side.LEFT)}</td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">${this.renderImage(Side.RIGHT)}</td>
+      </tr>
+    `;
+  }
+
+  private renderImage(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    if (!image) return nothing;
+    const error = side === Side.LEFT ? this.baseError : this.revisionError;
+    if (error) return error;
+    const src = imageSrc(image);
+    if (!src) return nothing;
+
+    return html`
+      <img
+        class="gr-diff ${side}"
+        src=${src}
+        @load=${this.handleLoad}
+        @error=${(e: Event) => this.handleError(e, side)}
+      >
+      </img>
+    `;
+  }
+
+  private handleLoad() {
+    this.requestUpdate();
+  }
+
+  private handleError(e: Event, side: Side) {
+    const msg = `[Image failed to load] ${e.type}`;
+    if (side === Side.LEFT) this.baseError = msg;
+    if (side === Side.RIGHT) this.revisionError = msg;
+  }
+
+  private renderImageLabelRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">
+          <label class="gr-diff">
+            ${this.renderName(this.baseImage?._name ?? '')}
+            <span class="gr-diff label">${this.imageLabel(Side.LEFT)}</span>
+          </label>
+        </td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">
+          <label class="gr-diff">
+            ${this.renderName(this.revisionImage?._name ?? '')}
+            <span class="gr-diff label"> ${this.imageLabel(Side.RIGHT)} </span>
+          </label>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderName(name?: string) {
+    const addNamesInLabel =
+      this.baseImage &&
+      this.revisionImage &&
+      this.baseImage._name !== this.revisionImage._name;
+    if (!addNamesInLabel) return nothing;
+    return html`
+      <span class="gr-diff name">${name}</span><br class="gr-diff" />
+    `;
+  }
+
+  private imageLabel(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    const imageEl =
+      side === Side.LEFT ? this.baseImageEl : this.revisionImageEl;
+    if (image) {
+      const type = image.type ?? image._expectedType;
+      if (imageEl?.naturalWidth && imageEl.naturalHeight) {
+        return `${imageEl?.naturalWidth}×${imageEl.naturalHeight} ${type}`;
+      } else {
+        return type;
+      }
+    }
+    return 'No image';
+  }
+}
+
+function imageSrc(image?: ImageInfo): string {
+  return image && IMAGE_MIME_PATTERN.test(image.type)
+    ? `data:${image.type};base64,${image.body}`
+    : '';
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-image-new': GrDiffImageNew;
+    'gr-diff-image-old': GrDiffImageOld;
+  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index 2c9f210..5270603 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -226,6 +226,7 @@
     }
 
     const cell = createElementDiff('td', 'dividerCell');
+    // Note that <td> table cells that have `display: none` don't count!
     const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
     cell.setAttribute('colspan', colspan);
     row.appendChild(cell);
@@ -346,7 +347,8 @@
   createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
-    side?: Side
+    side?: Side,
+    twoSlots?: boolean
   ) {
     const td = createElementDiff('td');
     if (line.type !== GrDiffLineType.BLANK) {
@@ -363,23 +365,26 @@
     }
     td.classList.add(line.type);
 
-    const {beforeNumber, afterNumber} = line;
-    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
+    const lineNumber = side ? line.lineNumber(side) : 0;
+    if (lineNumber === 'FILE') {
+      td.classList.add('file');
+    } else if (lineNumber === 'LOST') {
+      td.classList.add('lost');
+    } else {
       const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
+      const contentId =
+        side && lineNumber > 0 ? `${side}-content-${lineNumber}` : '';
       const contentText = formatText(
         line.text,
         responsiveMode,
         this._prefs.tab_size,
         this._prefs.line_length,
-        side === Side.LEFT
-          ? `left-content-${beforeNumber}`
-          : `right-content-${afterNumber}`
+        contentId
       );
 
       if (side) {
         contentText.setAttribute('data-side', side);
-        const number = side === Side.LEFT ? beforeNumber : afterNumber;
-        this.addLineNumberMouseEvents(td, number, side);
+        this.addLineNumberMouseEvents(td, lineNumber, side);
       }
 
       if (lineNumberEl && side) {
@@ -393,17 +398,25 @@
       }
 
       td.appendChild(contentText);
-    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
-    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
+    }
 
-    if (side && line.lineNumber(side)) {
-      const lineNumber = line.lineNumber(side);
+    if (side && lineNumber) {
       const threadGroupEl = document.createElement('div');
       threadGroupEl.className = 'thread-group';
       threadGroupEl.setAttribute('data-side', side);
+
       const slot = document.createElement('slot');
       slot.name = `${side}-${lineNumber}`;
       threadGroupEl.appendChild(slot);
+
+      // For line.type === BOTH in unified diff we want two slots.
+      if (twoSlots) {
+        const slot = document.createElement('slot');
+        const otherSide = side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+        slot.name = `${otherSide}-${line.lineNumber(otherSide)}`;
+        threadGroupEl.appendChild(slot);
+      }
+
       td.appendChild(threadGroupEl);
     }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
index 054311f..abe3c10 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
@@ -3,7 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {RenderPreferences} from '../../../api/diff';
+import {DiffViewMode, RenderPreferences} from '../../../api/diff';
 import {LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -12,7 +12,7 @@
 import {diffClasses} from '../gr-diff/gr-diff-utils';
 import {GrDiffBuilder} from './gr-diff-builder';
 import {BlameInfo} from '../../../types/common';
-import {html, render} from 'lit';
+import {html, nothing, render} from 'lit';
 import {GrDiffSection} from './gr-diff-section';
 import '../gr-context-controls/gr-context-controls';
 import './gr-diff-section';
@@ -128,10 +128,11 @@
   // lit element.
   protected override getMoveControlsConfig() {
     return {
-      numberOfCells: 4, // How many cells does the diff table have?
-      movedOutIndex: 1, // Index of left content column in diff table.
-      movedInIndex: 3, // Index of right content column in diff table.
-      lineNumberCols: [0, 2], // Indices of line number columns in diff table.
+      numberOfCells: 6, // How many cells does the diff table have?
+      movedOutIndex: 2, // Index of left content column in diff table.
+      movedInIndex: 5, // Index of right content column in diff table.
+      lineNumberCols: [0, 3], // Indices of line number columns in diff table.
+      signCols: {left: 1, right: 4},
     };
   }
 
@@ -159,17 +160,34 @@
     render(
       html`
         <colgroup>
-         <col class=${diffClasses('blame')}></col>
-         <col class=${diffClasses(Side.LEFT)} width=${lineNumberWidth}></col>
-         <col class=${diffClasses(Side.LEFT)}></col>
-         <col class=${diffClasses(Side.RIGHT)} width=${lineNumberWidth}></col>
-         <col class=${diffClasses(Side.RIGHT)}></col>
+          <col class=${diffClasses('blame')}></col>
+          ${this.renderUnifiedColumns(lineNumberWidth)}
+          ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+          ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
         </colgroup>
       `,
       outputEl
     );
   }
 
+  private renderUnifiedColumns(lineNumberWidth: number) {
+    if (this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED) return nothing;
+    return html`
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()}></col>
+    `;
+  }
+
+  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+    if (this.renderPrefs?.view_mode === DiffViewMode.UNIFIED) return nothing;
+    return html`
+      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+      <col class=${diffClasses(side, 'sign')}></col>
+      <col class=${diffClasses(side)}></col>
+    `;
+  }
+
   protected override getNextContentOnSide(
     _content: HTMLElement,
     _side: Side
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 10a947d..6ae7d4cd 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -83,14 +83,14 @@
     colgroup.appendChild(createElementDiff('col', 'left'));
 
     // Add right-side line number.
-    col = document.createElement('col');
+    col = createElementDiff('col', 'right');
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     colgroup.appendChild(createElementDiff('col', 'sign right'));
 
     // Add right-side content.
-    colgroup.appendChild(document.createElement('col'));
+    colgroup.appendChild(createElementDiff('col', 'right'));
 
     outputEl.appendChild(colgroup);
   }
@@ -108,24 +108,17 @@
     // pushing browser to compute aria even for tr. This can be removed, once
     // browsers will again compute a11y label even for tr when it is focused.
     // TODO: Remove when Chrome 102 is out of date for 1 year.
-    if (
-      leftLine.beforeNumber !== 'FILE' &&
-      leftLine.beforeNumber !== 'LOST' &&
-      rightLine.beforeNumber !== 'FILE' &&
-      rightLine.beforeNumber !== 'LOST'
-    ) {
-      row.setAttribute(
-        'aria-labelledby',
-        [
-          leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
-          leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
-          rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
-          rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
-        ]
-          .join(' ')
-          .trim()
-      );
-    }
+    row.setAttribute(
+      'aria-labelledby',
+      [
+        leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
+        leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
+        rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
+        rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
+      ]
+        .join(' ')
+        .trim()
+    );
 
     row.appendChild(this.createBlameCell(leftLine.beforeNumber));
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 8eb3694..ffc2dcd 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -77,17 +77,17 @@
     colgroup.appendChild(col);
 
     // Add left-side line number.
-    col = document.createElement('col');
+    col = createElementDiff('col');
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side line number.
-    col = document.createElement('col');
+    col = createElementDiff('col');
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add the content.
-    colgroup.appendChild(document.createElement('col'));
+    colgroup.appendChild(createElementDiff('col'));
 
     outputEl.appendChild(colgroup);
   }
@@ -126,24 +126,24 @@
     // pushing browser to compute aria even for tr. This can be removed, once
     // browsers will again compute a11y label even for tr when it is focused.
     // TODO: Remove when Chrome 102 is out of date for 1 year.
-    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
-      row.setAttribute(
-        'aria-labelledby',
-        [
-          line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
-          line.afterNumber ? `right-button-${line.afterNumber}` : '',
-          side === Side.LEFT && line.beforeNumber
-            ? `left-content-${line.beforeNumber}`
-            : '',
-          side === Side.RIGHT && line.afterNumber
-            ? `right-content-${line.afterNumber}`
-            : '',
-        ]
-          .join(' ')
-          .trim()
-      );
-    }
-    row.appendChild(this.createTextEl(lineNumberEl, line, side));
+    row.setAttribute(
+      'aria-labelledby',
+      [
+        line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
+        side === Side.LEFT && line.beforeNumber
+          ? `left-content-${line.beforeNumber}`
+          : '',
+        line.afterNumber ? `right-button-${line.afterNumber}` : '',
+        side === Side.RIGHT && line.afterNumber
+          ? `right-content-${line.afterNumber}`
+          : '',
+      ]
+        .filter(id => !!id)
+        .join(' ')
+        .trim()
+    );
+    const twoSlots = line.type === GrDiffLineType.BOTH;
+    row.appendChild(this.createTextEl(lineNumberEl, line, side, twoSlots));
     return row;
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index f3c88a9..5ca5197 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -64,6 +64,16 @@
   updateRenderPrefs(renderPrefs: RenderPreferences): void;
 }
 
+export interface ImageDiffBuilder extends DiffBuilder {
+  renderImageDiff(): void;
+}
+
+export function isImageDiffBuilder(
+  x: DiffBuilder | ImageDiffBuilder | undefined
+): x is ImageDiffBuilder {
+  return !!x && !!(x as ImageDiffBuilder).renderImageDiff;
+}
+
 /**
  * Base class for different diff builders, like side-by-side, unified etc.
  *
@@ -82,7 +92,7 @@
   // visible for testing
   readonly _prefs: DiffPreferencesInfo;
 
-  protected readonly renderPrefs?: RenderPreferences;
+  protected renderPrefs?: RenderPreferences;
 
   protected readonly outputEl: HTMLElement;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 4783042..14b8280 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -3,7 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {html, LitElement, TemplateResult} from 'lit';
+import {html, LitElement, nothing, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createRef, Ref, ref} from 'lit/directives/ref.js';
@@ -47,6 +47,13 @@
   @property({type: Object})
   responsiveMode?: DiffResponsiveMode;
 
+  /**
+   * true: side-by-side diff
+   * false: unified diff
+   */
+  @property({type: Boolean})
+  unifiedDiff = false;
+
   @property({type: Number})
   tabSize = 2;
 
@@ -60,14 +67,6 @@
   layers: DiffLayer[] = [];
 
   /**
-   * While not visible we are trying to optimize rendering performance by
-   * rendering a simpler version of the diff. Once this has become true it
-   * cannot be set back to false.
-   */
-  @state()
-  isVisible = false;
-
-  /**
    * Semantic DOM diff testing does not work with just table fragments, so when
    * running such tests the render() method has to wrap the DOM in a proper
    * <table> element.
@@ -120,7 +119,6 @@
    * `this.layersApplied = true`.
    */
   private async updateLayers(side: Side) {
-    if (!this.isVisible) return;
     const line = this.line(side);
     const contentEl = this.contentRef(side).value;
     const lineNumberEl = this.lineNumberRef(side).value;
@@ -139,37 +137,24 @@
     this.layersApplied = true;
   }
 
-  private renderInvisible() {
-    return html`
-      <tr>
-        <td class="gr-diff blame"></td>
-        <td class="gr-diff left"></td>
-        <td class="gr-diff left content">
-          <div>${this.left?.text ?? ''}</div>
-        </td>
-        <td class="gr-diff right"></td>
-        <td class="gr-diff right content">
-          <div>${this.right?.text ?? ''}</div>
-        </td>
-      </tr>
-    `;
-  }
-
   override render() {
     if (!this.left || !this.right) return;
-    if (!this.isVisible) return this.renderInvisible();
+    const classes = this.unifiedDiff ? ['unified'] : ['side-by-side'];
+    const unifiedType = this.unifiedType();
+    if (this.unifiedDiff && unifiedType) classes.push(unifiedType);
     const row = html`
       <tr
         ${ref(this.tableRowRef)}
-        class=${diffClasses('diff-row', 'side-by-side')}
-        left-type=${this.left.type}
-        right-type=${this.right.type}
+        class=${diffClasses('diff-row', ...classes)}
+        left-type=${ifDefined(this.getType(Side.LEFT))}
+        right-type=${ifDefined(this.getType(Side.RIGHT))}
         tabindex="-1"
+        aria-labelledby=${this.ariaLabelIds()}
       >
         ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
-        ${this.renderContentCell(Side.LEFT)}
+        ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)}
         ${this.renderLineNumberCell(Side.RIGHT)}
-        ${this.renderContentCell(Side.RIGHT)}
+        ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)}
       </tr>
     `;
     if (this.addTableWrapperForTesting) {
@@ -180,6 +165,33 @@
     return row;
   }
 
+  private ariaLabelIds() {
+    const ids: string[] = [];
+    ids.push(this.lineNumberId(Side.LEFT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT));
+    ids.push(this.lineNumberId(Side.RIGHT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT));
+    if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide()));
+    return ids.filter(id => !!id).join(' ');
+  }
+
+  private lineNumberId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-button-${lineNumber}`;
+  }
+
+  private unifiedSide() {
+    const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK;
+    return isLeft ? Side.LEFT : Side.RIGHT;
+  }
+
+  private contentId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-content-${lineNumber}`;
+  }
+
   getTableRow(): HTMLTableRowElement | undefined {
     return this.tableRowRef.value;
   }
@@ -242,15 +254,12 @@
   private renderLineNumberCell(side: Side): TemplateResult {
     const line = this.line(side);
     const lineNumber = this.lineNumber(side);
-    if (
-      !line ||
-      !lineNumber ||
-      line.type === GrDiffLineType.BLANK ||
-      this.layersApplied
-    ) {
+    const isBlank = line?.type === GrDiffLineType.BLANK;
+    if (!line || !lineNumber || isBlank || this.layersApplied) {
+      const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : '';
       return html`<td
         ${ref(this.lineNumberRef(side))}
-        class=${diffClasses(side)}
+        class=${diffClasses(side, blankClass)}
       ></td>`;
     }
 
@@ -274,6 +283,7 @@
     // prettier-ignore
     return html`
       <button
+        id=${this.lineNumberId(side)}
         class=${diffClasses('lineNumButton', side)}
         tabindex="-1"
         data-value=${lineNumber}
@@ -304,12 +314,19 @@
         return `${lineNumber} added`;
       case GrDiffLineType.BOTH:
       case GrDiffLineType.BLANK:
-        return undefined;
+        return `${lineNumber} unmodified`;
     }
   }
 
-  private renderContentCell(side: Side): TemplateResult {
-    const line = this.line(side);
+  private renderContentCell(side: Side) {
+    let line = this.line(side);
+    if (this.unifiedDiff) {
+      if (side === Side.LEFT) return nothing;
+      if (line?.type === GrDiffLineType.BLANK) {
+        side = Side.LEFT;
+        line = this.line(Side.LEFT);
+      }
+    }
     const lineNumber = this.lineNumber(side);
     assertIsDefined(line, 'line');
     const extras: string[] = [line.type, side];
@@ -331,21 +348,42 @@
           if (lineNumber)
             fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
         }}
-      >${this.renderText(side)}${this.renderThreadGroup(side, lineNumber)}</td>
+      >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
     `;
   }
 
-  private renderThreadGroup(side: Side, lineNumber?: LineNumber) {
-    if (!lineNumber) return;
-    // TODO: For the LOST line number the convention is that a <tr> will always
-    // be rendered, but it will not be visible, because of all cells being
-    // empty. For this to work with lit-based rendering we may only render a
-    // thread-group and a <slot> when there is a thread using that slot. The
-    // cleanest solution for that is probably introducing a gr-diff-model, where
-    // each diff row can look up or observe comment threads.
-    // .content has `white-space: pre`, so prettier must not add spaces.
-    // prettier-ignore
-    return html`<div class="thread-group" data-side=${side}><slot name="${side}-${lineNumber}"></slot></div>`;
+  private renderSignCell(side: Side) {
+    if (this.unifiedDiff) return nothing;
+    const line = this.line(side);
+    assertIsDefined(line, 'line');
+    const isBlank = line.type === GrDiffLineType.BLANK;
+    const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT;
+    const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT;
+    const extras: string[] = ['sign', side];
+    if (isBlank) extras.push('blank');
+    if (isAdd) extras.push('add');
+    if (isRemove) extras.push('remove');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+
+    const sign = isAdd ? '+' : isRemove ? '-' : '';
+    return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+  }
+
+  private renderThreadGroup(side: Side) {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return nothing;
+    return html`<div class="thread-group" data-side=${side}>
+      <slot name="${side}-${lineNumber}"></slot>
+      ${this.renderSecondSlot()}
+    </div>`;
+  }
+
+  private renderSecondSlot() {
+    if (!this.unifiedDiff) return nothing;
+    if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
+    return html`<slot
+      name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
+    ></slot>`;
   }
 
   private contentRef(side: Side) {
@@ -366,6 +404,19 @@
     return side === Side.LEFT ? this.left : this.right;
   }
 
+  private getType(side?: Side): string | undefined {
+    if (this.unifiedDiff) return undefined;
+    if (side === Side.LEFT) return this.left?.type;
+    if (side === Side.RIGHT) return this.right?.type;
+    return undefined;
+  }
+
+  private unifiedType() {
+    return this.left?.type === GrDiffLineType.BLANK
+      ? this.right?.type
+      : this.left?.type;
+  }
+
   /**
    * Returns a 'div' element containing the supplied |text| as its innerText,
    * with '\t' characters expanded to a width determined by |tabSize|, and the
@@ -391,9 +442,9 @@
     // .content has `white-space: pre`, so prettier must not add spaces.
     // prettier-ignore
     return html`<div
-        class=${diffClasses('contentText', side)}
-        .ariaLabel=${line?.text ?? ''}
+        class=${diffClasses('contentText')}
         data-side=${ifDefined(side)}
+        id=${this.contentId(side)}
       >${textElement}</div>`;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
index 4e8bb62..1c7b311 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -15,7 +15,6 @@
 
   setup(async () => {
     element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
-    element.isVisible = true;
     element.addTableWrapperForTesting = true;
     await element.updateComplete;
   });
@@ -32,6 +31,7 @@
         <table>
           <tbody>
             <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
               class="diff-row gr-diff side-by-side"
               left-type="both"
               right-type="both"
@@ -40,20 +40,23 @@
               <td class="blame gr-diff" data-line-number="1"></td>
               <td class="gr-diff left lineNum" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff left lineNumButton"
                   data-value="1"
+                  id="left-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
               <td class="both content gr-diff left no-intraline-info">
                 <div
-                  aria-label="lorem ipsum"
-                  class="contentText gr-diff left"
+                  class="contentText gr-diff"
                   data-side="left"
+                  id="left-content-1"
                 >
-                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -61,8 +64,70 @@
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff lineNumButton right"
                   data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('both unified', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    element.unifiedDiff = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 right-button-1 right-content-1"
+              class="both diff-row gr-diff unified"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
                   tabindex="-1"
                 >
                   1
@@ -70,14 +135,15 @@
               </td>
               <td class="both content gr-diff no-intraline-info right">
                 <div
-                  aria-label="lorem ipsum"
-                  class="contentText gr-diff right"
+                  class="contentText gr-diff"
                   data-side="right"
+                  id="right-content-1"
                 >
-                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
+                  <slot name="left-1"> </slot>
                 </div>
               </td>
             </tr>
@@ -99,37 +165,37 @@
         <table>
           <tbody>
             <tr
+              aria-labelledby="right-button-1 right-content-1"
               class="diff-row gr-diff side-by-side"
               left-type="blank"
               right-type="add"
               tabindex="-1"
             >
               <td class="blame gr-diff" data-line-number="0"></td>
-              <td class="gr-diff left"></td>
+              <td class="blankLineNum gr-diff left"></td>
+              <td class="blank gr-diff left no-intraline-info sign"></td>
               <td class="blank gr-diff left no-intraline-info">
-                <div
-                  aria-label=""
-                  class="contentText gr-diff left"
-                  data-side="left"
-                ></div>
+                <div class="contentText gr-diff" data-side="left"></div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
                   aria-label="1 added"
                   class="gr-diff lineNumButton right"
                   data-value="1"
+                  id="right-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="add gr-diff no-intraline-info right sign">+</td>
               <td class="add content gr-diff no-intraline-info right">
                 <div
-                  aria-label="lorem ipsum"
-                  class="contentText gr-diff right"
+                  class="contentText gr-diff"
                   data-side="right"
+                  id="right-content-1"
                 >
-                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
@@ -154,6 +220,7 @@
         <table>
           <tbody>
             <tr
+              aria-labelledby="left-button-1 left-content-1"
               class="diff-row gr-diff side-by-side"
               left-type="remove"
               right-type="blank"
@@ -165,30 +232,29 @@
                   aria-label="1 removed"
                   class="gr-diff left lineNumButton"
                   data-value="1"
+                  id="left-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff left no-intraline-info remove sign">-</td>
               <td class="content gr-diff left no-intraline-info remove">
                 <div
-                  aria-label="lorem ipsum"
-                  class="contentText gr-diff left"
+                  class="contentText gr-diff"
                   data-side="left"
+                  id="left-content-1"
                 >
-                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
                 </div>
               </td>
-              <td class="gr-diff right"></td>
+              <td class="blankLineNum gr-diff right"></td>
+              <td class="blank gr-diff no-intraline-info right sign"></td>
               <td class="blank gr-diff no-intraline-info right">
-                <div
-                  aria-label=""
-                  class="contentText gr-diff right"
-                  data-side="right"
-                ></div>
+                <div class="contentText gr-diff" data-side="right"></div>
               </td>
             </tr>
           </tbody>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index 16c7cb3..1c09372 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -22,7 +22,6 @@
 import '../gr-context-controls/gr-context-controls';
 import '../gr-range-header/gr-range-header';
 import './gr-diff-row';
-import {whenVisible} from '../../../utils/dom-util';
 
 @customElement('gr-diff-section')
 export class GrDiffSection extends LitElement {
@@ -42,13 +41,6 @@
   layers: DiffLayer[] = [];
 
   /**
-   * While not visible we are trying to optimize rendering performance by
-   * rendering a simpler version of the diff.
-   */
-  @state()
-  isVisible = false;
-
-  /**
    * Semantic DOM diff testing does not work with just table fragments, so when
    * running such tests the render() method has to wrap the DOM in a proper
    * <table> element.
@@ -67,12 +59,6 @@
     return this;
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    // TODO: Refine this obviously simplistic approach to optimized rendering.
-    whenVisible(this.parentElement!, () => (this.isVisible = true), 1000);
-  }
-
   override render() {
     if (!this.group) return;
     const extras: string[] = [];
@@ -84,8 +70,7 @@
     if (this.group.moveDetails?.changed) extras.push('changed');
     if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
 
-    const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
-    const pairs = isControl ? [] : this.group.getSideBySidePairs();
+    const pairs = this.getLinePairs();
     const body = html`
       <tbody class=${diffClasses(...extras)}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
@@ -100,7 +85,7 @@
               .layers=${this.layers}
               .lineLength=${this.diffPrefs?.line_length ?? 80}
               .tabSize=${this.diffPrefs?.tab_size ?? 2}
-              .isVisible=${this.isVisible}
+              .unifiedDiff=${this.isUnifiedDiff()}
             >
             </gr-diff-row>
           `;
@@ -115,6 +100,19 @@
     return body;
   }
 
+  private isUnifiedDiff() {
+    return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+  }
+
+  getLinePairs() {
+    if (!this.group) return [];
+    const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (isControl) return [];
+    return this.isUnifiedDiff()
+      ? this.group.getUnifiedPairs()
+      : this.group.getSideBySidePairs();
+  }
+
   getDiffRows(): GrDiffRow[] {
     return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
   }
@@ -140,7 +138,6 @@
         .group=${this.group}
         .diff=${this.diff}
         .renderPrefs=${this.renderPrefs}
-        .viewMode=${DiffViewMode.SIDE_BY_SIDE}
       >
       </gr-context-controls-section>
     `;
@@ -157,6 +154,7 @@
     if (!this.group?.moveDetails) return;
     const movedIn = this.group.adds.length > 0;
     const plainCell = html`<td class=${diffClasses()}></td>`;
+    const signCell = html`<td class=${diffClasses('sign')}></td>`;
     const lineNumberCell = html`
       <td class=${diffClasses('moveControlsLineNumCol')}></td>
     `;
@@ -171,8 +169,8 @@
       <tr
         class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
       >
-        ${lineNumberCell} ${movedIn ? plainCell : moveCell} ${lineNumberCell}
-        ${movedIn ? moveCell : plainCell}
+        ${lineNumberCell} ${signCell} ${movedIn ? plainCell : moveCell}
+        ${lineNumberCell} ${signCell} ${movedIn ? moveCell : plainCell}
       </tr>
     `;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index 363b001..33b3df0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -19,7 +19,6 @@
       html`<gr-diff-section></gr-diff-section>`
     );
     element.addTableWrapperForTesting = true;
-    element.isVisible = true;
     await element.updateComplete;
   });
 
@@ -44,6 +43,7 @@
         <table>
           <tbody class="both gr-diff section">
             <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
               class="diff-row gr-diff side-by-side"
               left-type="both"
               right-type="both"
@@ -52,20 +52,23 @@
               <td class="blame gr-diff" data-line-number="1"></td>
               <td class="gr-diff left lineNum" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff left lineNumButton"
                   data-value="1"
+                  id="left-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
               <td class="both content gr-diff left no-intraline-info">
                 <div
-                  aria-label="asdf"
-                  class="contentText gr-diff left"
+                  class="contentText gr-diff"
                   data-side="left"
+                  id="left-content-1"
                 >
-                  <gr-diff-text></gr-diff-text>
+                  <gr-diff-text> </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -73,20 +76,23 @@
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff lineNumButton right"
                   data-value="1"
+                  id="right-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
               <td class="both content gr-diff no-intraline-info right">
                 <div
-                  aria-label="asdf"
-                  class="contentText gr-diff right"
+                  class="contentText gr-diff"
                   data-side="right"
+                  id="right-content-1"
                 >
-                  <gr-diff-text></gr-diff-text>
+                  <gr-diff-text> </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
@@ -94,6 +100,7 @@
               </td>
             </tr>
             <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
               class="diff-row gr-diff side-by-side"
               left-type="both"
               right-type="both"
@@ -102,20 +109,23 @@
               <td class="blame gr-diff" data-line-number="1"></td>
               <td class="gr-diff left lineNum" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff left lineNumButton"
                   data-value="1"
+                  id="left-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
               <td class="both content gr-diff left no-intraline-info">
                 <div
-                  aria-label="qwer"
-                  class="contentText gr-diff left"
+                  class="contentText gr-diff"
                   data-side="left"
+                  id="left-content-1"
                 >
-                  <gr-diff-text></gr-diff-text>
+                  <gr-diff-text> </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -123,20 +133,23 @@
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff lineNumButton right"
                   data-value="1"
+                  id="right-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
               <td class="both content gr-diff no-intraline-info right">
                 <div
-                  aria-label="qwer"
-                  class="contentText gr-diff right"
+                  class="contentText gr-diff"
                   data-side="right"
+                  id="right-content-1"
                 >
-                  <gr-diff-text></gr-diff-text>
+                  <gr-diff-text> </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
@@ -144,6 +157,7 @@
               </td>
             </tr>
             <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
               class="diff-row gr-diff side-by-side"
               left-type="both"
               right-type="both"
@@ -152,20 +166,23 @@
               <td class="blame gr-diff" data-line-number="1"></td>
               <td class="gr-diff left lineNum" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff left lineNumButton"
                   data-value="1"
+                  id="left-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
               <td class="both content gr-diff left no-intraline-info">
                 <div
-                  aria-label="zxcv"
-                  class="contentText gr-diff left"
+                  class="contentText gr-diff"
                   data-side="left"
+                  id="left-content-1"
                 >
-                  <gr-diff-text></gr-diff-text>
+                  <gr-diff-text> </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="left">
                   <slot name="left-1"> </slot>
@@ -173,20 +190,23 @@
               </td>
               <td class="gr-diff lineNum right" data-value="1">
                 <button
+                  aria-label="1 unmodified"
                   class="gr-diff lineNumButton right"
                   data-value="1"
+                  id="right-button-1"
                   tabindex="-1"
                 >
                   1
                 </button>
               </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
               <td class="both content gr-diff no-intraline-info right">
                 <div
-                  aria-label="zxcv"
-                  class="contentText gr-diff right"
+                  class="contentText gr-diff"
                   data-side="right"
+                  id="right-content-1"
                 >
-                  <gr-diff-text></gr-diff-text>
+                  <gr-diff-text> </gr-diff-text>
                 </div>
                 <div class="thread-group" data-side="right">
                   <slot name="right-1"> </slot>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index 1e5dd65..e9076aa 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -139,11 +139,17 @@
     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;
+      if (token.length > TOKEN_LENGTH_LIMIT) continue;
+
+      // This is to correctly count surrogate pairs in text and token.
+      // If the index calculation becomes a hotspot, we could precompute a code
+      // unit to code point index map for text before iterating over the results
+      const index = GrAnnotation.getStringLength(text.slice(0, match.index));
+      const length = GrAnnotation.getStringLength(token);
+
       atLeastOneTokenMatched = true;
       const highlightTypeClass =
         token === this.currentHighlight ? CSS_HIGHLIGHT : '';
@@ -339,7 +345,7 @@
       start_line: line,
       start_column: index + 1, // 1-based inclusive
       end_line: line,
-      end_column: index + token.length, // 1-based inclusive
+      end_column: index + GrAnnotation.getStringLength(token), // 1-based inclusive
     };
     this.tokenHighlightListener({token, element, side, range});
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 1beed46..8fd03bb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -105,15 +105,17 @@
   suite('annotate', () => {
     function assertAnnotation(
       args: any[],
-      el: HTMLElement,
-      start: number,
-      length: number,
-      cssClass: string
+      expected: {
+        parent: HTMLElement;
+        offset: number;
+        length: number;
+        cssClass: string;
+      }
     ) {
-      assert.equal(args[0], el);
-      assert.equal(args[1], start);
-      assert.equal(args[2], length);
-      assert.equal(args[3], cssClass);
+      assert.equal(args[0], expected.parent);
+      assert.equal(args[1], expected.offset);
+      assert.equal(args[2], expected.length);
+      assert.equal(args[3], expected.cssClass);
     }
 
     test('annotate adds css token', () => {
@@ -121,27 +123,51 @@
       const el = createLine('these are words');
       annotate(el);
       assert.isTrue(annotateElementStub.calledThrice);
-      assertAnnotation(
-        annotateElementStub.args[0],
-        el,
-        0,
-        5,
-        'tk-text-these tk-index-0 token '
-      );
-      assertAnnotation(
-        annotateElementStub.args[1],
-        el,
-        6,
-        3,
-        'tk-text-are tk-index-6 token '
-      );
-      assertAnnotation(
-        annotateElementStub.args[2],
-        el,
-        10,
-        5,
-        'tk-text-words tk-index-10 token '
-      );
+      assertAnnotation(annotateElementStub.args[0], {
+        parent: el,
+        offset: 0,
+        length: 5,
+        cssClass: 'tk-text-these tk-index-0 token ',
+      });
+      assertAnnotation(annotateElementStub.args[1], {
+        parent: el,
+        offset: 6,
+        length: 3,
+        cssClass: 'tk-text-are tk-index-6 token ',
+      });
+      assertAnnotation(annotateElementStub.args[2], {
+        parent: el,
+        offset: 10,
+        length: 5,
+        cssClass: 'tk-text-words tk-index-10 token ',
+      });
+    });
+
+    test('annotate adds css tokens w/ emojis', () => {
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const el = createLine('these 💩 are 👨‍👩‍👧‍👦 words');
+
+      annotate(el);
+
+      assert.isTrue(annotateElementStub.calledThrice);
+      assertAnnotation(annotateElementStub.args[0], {
+        parent: el,
+        offset: 0,
+        length: 5,
+        cssClass: 'tk-text-these tk-index-0 token ',
+      });
+      assertAnnotation(annotateElementStub.args[1], {
+        parent: el,
+        offset: 8,
+        length: 3,
+        cssClass: 'tk-text-are tk-index-8 token ',
+      });
+      assertAnnotation(annotateElementStub.args[2], {
+        parent: el,
+        offset: 20,
+        length: 5,
+        cssClass: 'tk-text-words tk-index-20 token ',
+      });
     });
 
     test('annotate adds mouse handlers', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 35439d6..e5fce12 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -423,7 +423,10 @@
   }
 
   _rowHasThread(row: HTMLElement): boolean {
-    return !!row.querySelector('.thread-group');
+    const slots = [
+      ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
+    ];
+    return slots.some(slot => slot.assignedElements().length > 0);
   }
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
index cc7cd49..38bd707 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -22,8 +22,14 @@
     return this.getStringLength(node.textContent || '');
   },
 
+  /**
+   * Returns the number of Unicode code points in the given string
+   *
+   * This is not necessarily the same as the number of visible symbols.
+   * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+   */
   getStringLength(str: string) {
-    return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+    return [...str].length;
   },
 
   /**
@@ -165,18 +171,20 @@
     cssClass: string,
     firstPart?: boolean
   ) {
-    if (this.getLength(node) === offset || offset === 0) {
-      return this.wrapInHighlight(node, cssClass);
-    } else {
-      if (firstPart) {
-        this.splitNode(node, offset);
-        // Node points to first part of the Text, second one is sibling.
-      } else {
-        // if node is Text then splitNode will return a Text
-        node = this.splitNode(node, offset) as Text;
-      }
+    if (
+      (this.getLength(node) === offset && firstPart) ||
+      (offset === 0 && !firstPart)
+    ) {
       return this.wrapInHighlight(node, cssClass);
     }
+    if (firstPart) {
+      this.splitNode(node, offset);
+      // Node points to first part of the Text, second one is sibling.
+    } else {
+      // if node is Text then splitNode will return a Text
+      node = this.splitNode(node, offset) as Text;
+    }
+    return this.wrapInHighlight(node, cssClass);
   },
 
   /**
@@ -219,7 +227,6 @@
    */
   splitTextNode(node: Text, offset: number) {
     if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
-      // TODO (viktard): Polyfill Array.from for IE10.
       const head = Array.from(node.textContent);
       const tail = head.splice(offset);
       const parent = node.parentNode;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
index 6c45f20..f319a3c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -27,79 +27,77 @@
     str = textNode.textContent!;
   });
 
+  test('_annotateText length:0 offset:0', () => {
+    GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar"></hl>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:1', () => {
+    GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'L<hl class="foobar"></hl>orem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:str.length', () => {
+    GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula<hl class="foobar"></hl>'
+    );
+  });
+
   test('_annotateText Case 1', () => {
     GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
 
-    assert.equal(parent.childNodes.length, 1);
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    const firstChild = parent.childNodes[0] as HTMLElement;
-    assert.equal(firstChild.className, 'foobar');
-    assert.instanceOf(firstChild.childNodes[0], Text);
-    assert.equal(firstChild.childNodes[0].textContent, str);
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
   });
 
   test('_annotateText Case 2', () => {
-    const length = 12;
-    const substr = str.substr(0, length);
-    const remainder = str.substr(length);
+    GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
 
-    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    const firstChild = parent.childNodes[0] as HTMLElement;
-    assert.equal(firstChild.className, 'foobar');
-    assert.instanceOf(firstChild.childNodes[0], Text);
-    assert.equal(firstChild.childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[1], Text);
-    assert.equal(parent.childNodes[1].textContent, remainder);
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum </hl>dolor sit amet, suspendisse inceptos vehicula'
+    );
   });
 
   test('_annotateText Case 3', () => {
-    const index = 12;
-    const length = str.length - index;
-    const remainder = str.substr(0, index);
-    const substr = str.substr(index);
+    GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
 
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainder);
-
-    const secondChild = parent.childNodes[1] as HTMLElement;
-    assert.instanceOf(secondChild, HTMLElement);
-    assert.equal(secondChild.className, 'foobar');
-    assert.instanceOf(secondChild.childNodes[0], Text);
-    assert.equal(secondChild.childNodes[0].textContent, substr);
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
   });
 
   test('_annotateText Case 4', () => {
     const index = str.indexOf('dolor');
     const length = 'dolor '.length;
 
-    const remainderPre = str.substr(0, index);
-    const substr = str.substr(index, length);
-    const remainderPost = str.substr(index + length);
-
     GrAnnotation._annotateText(textNode, index, length, 'foobar');
 
-    assert.equal(parent.childNodes.length, 3);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainderPre);
-
-    const secondChild = parent.childNodes[1] as HTMLElement;
-    assert.instanceOf(secondChild, HTMLElement);
-    assert.equal(secondChild.className, 'foobar');
-    assert.instanceOf(secondChild.childNodes[0], Text);
-    assert.equal(secondChild.childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[2], Text);
-    assert.equal(parent.childNodes[2].textContent, remainderPost);
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor </hl>sit amet, suspendisse inceptos vehicula'
+    );
   });
 
   test('_annotateElement design doc example', () => {
@@ -116,45 +114,9 @@
     });
 
     assert.equal(parent.textContent, str);
-
-    // Layer 1:
-    const layer1 = parent.querySelectorAll<HTMLElement>('.layer-1');
-    assert.equal(layer1.length, 1);
-    assert.equal(layer1[0].textContent, layers[0]);
-    assert.equal(layer1[0].parentElement, parent);
-
-    // Layer 2:
-    const layer2 = parent.querySelectorAll<HTMLElement>('.layer-2');
-    assert.equal(layer2.length, 1);
-    assert.equal(layer2[0].textContent, layers[1]);
-    assert.equal(layer2[0].parentElement, parent);
-
-    // Layer 3:
-    const layer3 = parent.querySelectorAll<HTMLElement>('.layer-3');
-    assert.equal(layer3.length, 1);
-    assert.equal(layer3[0].textContent, layers[2]);
-    assert.equal(layer3[0].parentElement, layer1[0]);
-
-    // Layer 4:
-    const layer4 = parent.querySelectorAll<HTMLElement>('.layer-4');
-    assert.equal(layer4.length, 3);
-
-    assert.equal(layer4[0].textContent, 'et, ');
-    assert.equal(layer4[0].parentElement, layer3[0]);
-
-    assert.equal(layer4[1].textContent, 'suspendisse ');
-    assert.equal(layer4[1].parentElement, parent);
-
-    assert.equal(layer4[2].textContent, 'ince');
-    assert.equal(layer4[2].parentElement, layer2[0]);
-
     assert.equal(
-      [
-        layer4[0].textContent,
-        layer4[1].textContent,
-        layer4[2].textContent,
-      ].join(''),
-      layers[3]
+      parent.innerHTML,
+      'Lorem ipsum dolor sit <hl class="layer-1"><hl class="layer-3">am<hl class="layer-4">et, </hl></hl></hl><hl class="layer-4">suspendisse </hl><hl class="layer-2"><hl class="layer-4">ince</hl>ptos </hl>vehicula'
     );
   });
 
@@ -327,4 +289,20 @@
       assert.equal(el.getAttribute('class'), 'hello world');
     });
   });
+
+  suite('getStringLength', () => {
+    test('ASCII characters are counted correctly', () => {
+      assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+    });
+
+    test('Unicode surrogate pairs count as one symbol', () => {
+      assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
+      assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+    });
+
+    test('Grapheme clusters count as multiple symbols', () => {
+      assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
+      assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+    });
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 9f874b8..22a71a5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -21,6 +21,7 @@
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {RenderPreferences} from '../../../api/diff';
 import {assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 
 const WHOLE_FILE = -1;
 
@@ -639,16 +640,19 @@
     rows: string[],
     intralineInfos: number[][]
   ): Highlights[] {
+    // +1 to account for the \n that is not part of the rows passed here
+    const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+
     let rowIndex = 0;
     let idx = 0;
     const normalized = [];
     for (const [skipLength, markLength] of intralineInfos) {
-      let line = rows[rowIndex] + '\n';
+      let lineLength = lineLengths[rowIndex];
       let j = 0;
       while (j < skipLength) {
-        if (idx === line.length) {
+        if (idx === lineLength) {
           idx = 0;
-          line = rows[++rowIndex] + '\n';
+          lineLength = lineLengths[++rowIndex];
           continue;
         }
         idx++;
@@ -660,10 +664,10 @@
       };
 
       j = 0;
-      while (line && j < markLength) {
-        if (idx === line.length) {
+      while (lineLength && j < markLength) {
+        if (idx === lineLength) {
           idx = 0;
-          line = rows[++rowIndex] + '\n';
+          lineLength = lineLengths[++rowIndex];
           normalized.push(lineHighlight);
           lineHighlight = {
             contentIndex: rowIndex,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 6caeb62..f6f7052 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -723,6 +723,25 @@
           endIndex: 41,
         },
       ]);
+
+      content = ['🙈 a', '🙉 b', '🙊 c'];
+      highlights = [[2, 7]];
+      results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 2,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 1,
+        },
+      ]);
     });
 
     test('scrolling pauses rendering', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index bb04245..7a724b9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -8,6 +8,7 @@
 import {LineNumber} from './gr-diff-line';
 import {assertIsDefined, assert} from '../../../utils/common-util';
 import {untilRendered} from '../../../utils/dom-util';
+import {isDefined} from '../../../types/types';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -385,10 +386,7 @@
       this.type === GrDiffGroupType.CONTEXT_CONTROL
     ) {
       return this.lines.map(line => {
-        return {
-          left: line,
-          right: line,
-        };
+        return {left: line, right: line};
       });
     }
 
@@ -406,6 +404,21 @@
     return pairs;
   }
 
+  getUnifiedPairs(): GrDiffLinePair[] {
+    return this.lines
+      .map(line => {
+        if (line.type === GrDiffLineType.ADD) {
+          return {left: BLANK_LINE, right: line};
+        }
+        if (line.type === GrDiffLineType.REMOVE) {
+          if (this.ignoredWhitespaceOnly) return undefined;
+          return {left: line, right: BLANK_LINE};
+        }
+        return {left: line, right: line};
+      })
+      .filter(isDefined);
+  }
+
   /** Returns true if it is, or contains, a skip group. */
   hasSkipGroup() {
     return !!this.skip || this.contextGroups?.some(g => !!g.skip);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index f9a31b4..569de48 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -973,6 +973,8 @@
           z-index: 10;
         }
 
+        gr-diff-image-new,
+        gr-diff-image-old,
         gr-diff-section,
         gr-context-controls-section,
         gr-diff-row {
@@ -1004,6 +1006,7 @@
     if (this.diffTable && this.diffBuilder) {
       this.highlights.init(this.diffTable, this.diffBuilder);
     }
+    this.diffBuilder.init();
   }
 
   override disconnectedCallback() {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 1db3945..ce1393d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -68,13 +68,1289 @@
       );
     });
 
+    test('a unified diff legacy', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      await testUnified();
+    });
+
+    test('a unified diff lit', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      element.renderPrefs = {...element.renderPrefs, use_lit_components: true};
+      await testUnified();
+    });
+
+    const testUnified = async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer unified">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 right-button-1 right-content-1"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 right-button-2 right-content-2"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 right-button-3 right-content-3"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 right-button-4 right-content-4"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 right-button-8 right-content-8"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 right-button-9 right-content-9"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 right-button-10 right-content-10"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 right-button-11 right-content-11"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 right-button-12 right-content-12"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="right-button-13 right-content-13"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-14 right-content-14"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-15 right-content-15"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 right-button-16 right-content-16"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 right-button-17 right-content-17"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 right-button-18 right-content-18"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr class="above contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls class="gr-diff" showconfig="both">
+                    </gr-context-controls>
+                  </td>
+                </tr>
+                <tr class="below contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 right-button-37 right-content-37"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 right-button-38 right-content-38"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 right-button-39 right-content-39"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 right-button-44 right-content-44"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 right-button-45 right-content-45"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 right-button-46 right-content-46"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 right-button-47 right-content-47"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 right-button-48 right-content-48"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    };
+
     test('a normal diff legacy', async () => {
       await testNormal();
     });
 
     test('a normal diff lit', async () => {
-      // TODO(brohlfs): Make sure that test passes. Then uncomment next line.
-      // element.renderPrefs = {...element.renderPrefs, use_lit_components: true};
+      element.renderPrefs = {...element.renderPrefs, use_lit_components: true};
       await testNormal();
     });
 
@@ -93,12 +1369,13 @@
                 <col class="gr-diff left" width="48" />
                 <col class="gr-diff left sign" />
                 <col class="gr-diff left" />
-                <col width="48" />
+                <col class="gr-diff right" width="48" />
                 <col class="gr-diff right sign" />
-                <col />
+                <col class="gr-diff right" />
               </colgroup>
               <tbody class="both gr-diff section">
                 <tr
+                  aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
                   class="diff-row gr-diff side-by-side"
                   left-type="both"
                   right-type="both"
@@ -119,6 +1396,7 @@
               </tbody>
               <tbody class="both gr-diff section">
                 <tr
+                  aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
                   class="diff-row gr-diff side-by-side"
                   left-type="both"
                   right-type="both"
@@ -367,11 +1645,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="5">
                     <button
@@ -405,11 +1679,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="6">
                     <button
@@ -443,11 +1713,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="7">
                     <button
@@ -750,11 +2016,7 @@
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
                   <td class="blank gr-diff no-intraline-info right">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="right"
-                      id="right-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -788,11 +2050,7 @@
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
                   <td class="blank gr-diff no-intraline-info right">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="right"
-                      id="right-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -826,11 +2084,7 @@
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
                   <td class="blank gr-diff no-intraline-info right">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="right"
-                      id="right-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
@@ -864,11 +2118,7 @@
                   <td class="blankLineNum gr-diff right"></td>
                   <td class="blank gr-diff no-intraline-info right sign"></td>
                   <td class="blank gr-diff no-intraline-info right">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="right"
-                      id="right-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
@@ -1371,11 +2621,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="40">
                     <button
@@ -1409,11 +2655,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="41">
                     <button
@@ -1447,11 +2689,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="42">
                     <button
@@ -1485,11 +2723,7 @@
                   <td class="blankLineNum gr-diff left"></td>
                   <td class="blank gr-diff left no-intraline-info sign"></td>
                   <td class="blank gr-diff left no-intraline-info">
-                    <div
-                      class="contentText gr-diff"
-                      data-side="left"
-                      id="left-content-0"
-                    ></div>
+                    <div class="contentText gr-diff" data-side="left"></div>
                   </td>
                   <td class="gr-diff lineNum right" data-value="43">
                     <button
@@ -1764,7 +2998,14 @@
           </div>
         `,
         {
-          ignoreTags: ['gr-legacy-text', 'slot'],
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
         }
       );
     };
@@ -1912,6 +3153,83 @@
       assert.isTrue(container.classList.contains('displayLine'));
     });
 
+    suite('binary diffs', () => {
+      test('render binary diff', async () => {
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        element.diff = {
+          meta_a: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          meta_b: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          change_type: 'MODIFIED',
+          intraline_status: 'OK',
+          diff_header: [],
+          content: [],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
+
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <div class="diffContainer sideBySide">
+              <table class="selected-right" id="diffTable">
+                <colgroup>
+                  <col class="blame gr-diff" />
+                  <col class="gr-diff" width="48" />
+                  <col class="gr-diff" width="48" />
+                  <col class="gr-diff" />
+                </colgroup>
+                <tbody class="binary-diff gr-diff"></tbody>
+                <tbody class="binary-diff gr-diff">
+                  <tr
+                    aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+                    class="both diff-row gr-diff unified"
+                    tabindex="-1"
+                  >
+                    <td class="blame gr-diff" data-line-number="FILE"></td>
+                    <td class="gr-diff left lineNum" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff left lineNumButton"
+                        data-value="FILE"
+                        id="left-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td class="gr-diff lineNum right" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff lineNumButton right"
+                        data-value="FILE"
+                        id="right-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td
+                      class="both content file gr-diff no-intraline-info right"
+                    >
+                      <div>
+                        <span> Difference in binary files </span>
+                      </div>
+                      <div class="thread-group" data-side="right">
+                        <slot name="right-FILE"> </slot>
+                        <slot name="left-FILE"> </slot>
+                      </div>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          `
+        );
+      });
+    });
+
     suite('image diffs', () => {
       let mockFile1: ImageInfo;
       let mockFile2: ImageInfo;
@@ -1974,14 +3292,14 @@
                 <td class="blank gr-diff left lineNum"></td>
                 <td class="gr-diff left">
                   <img
-                    class="gr-diff"
+                    class="gr-diff left"
                     src="data:image/bmp;base64,${mockFile1.body}"
                   />
                 </td>
                 <td class="blank gr-diff lineNum right"></td>
                 <td class="gr-diff right">
                   <img
-                    class="gr-diff"
+                    class="gr-diff right"
                     src="data:image/bmp;base64,${mockFile2.body}"
                   />
                 </td>
@@ -2003,6 +3321,22 @@
             </tbody>
           `
         );
+        const endpoint = queryAndAssert(element, 'tbody.endpoint');
+        assert.dom.equal(
+          endpoint,
+          /* HTML */ `
+            <tbody class="gr-diff endpoint">
+              <tr class="gr-diff">
+                <gr-endpoint-decorator class="gr-diff" name="image-diff">
+                  <gr-endpoint-param class="gr-diff" name="baseImage">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param class="gr-diff" name="revisionImage">
+                  </gr-endpoint-param>
+                </gr-endpoint-decorator>
+              </tr>
+            </tbody>
+          `
+        );
       });
 
       test('renders image diffs with a different file name', async () => {
@@ -2081,7 +3415,7 @@
           rightImage,
           /* HTML */ `
             <img
-              class="gr-diff"
+              class="gr-diff right"
               src="data:image/bmp;base64,${mockFile2.body}"
             />
           `
@@ -2115,7 +3449,7 @@
           leftImage,
           /* HTML */ `
             <img
-              class="gr-diff"
+              class="gr-diff left"
               src="data:image/bmp;base64,${mockFile1.body}"
             />
           `
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 58f3f75..38eecfa 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -144,12 +144,13 @@
       side,
       range,
       operation: (forLine, startChar, endChar) => {
-        forLine.push({
-          start: startChar,
-          end: endChar,
-          id: id(commentRange),
-          longRange,
-        });
+        if (startChar !== endChar)
+          forLine.push({
+            start: startChar,
+            end: endChar,
+            id: id(commentRange),
+            longRange,
+          });
       },
     });
   }
@@ -202,7 +203,7 @@
       // Normalize invalid ranges where the start is after the end but the
       // start still makes sense. Set the end to the end of the line.
       // @see Issue 5744
-      if (range.start >= range.end && range.start < line.text.length) {
+      if (range.start > range.end && range.start < line.text.length) {
         range.end = line.text.length;
       }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 33515b25..7feda47 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -69,6 +69,16 @@
   },
 };
 
+const rangeF: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 0,
+    end_line: 24,
+    start_character: 0,
+    start_line: 23,
+  },
+};
+
 suite('gr-ranged-comment-layer', () => {
   let element: GrRangedCommentLayer;
 
@@ -79,6 +89,7 @@
       rangeC,
       rangeD,
       rangeE,
+      rangeF,
     ];
 
     element = new GrRangedCommentLayer();
@@ -219,6 +230,16 @@
       );
     });
 
+    test('do not annotate lines with end_character 0', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.afterNumber = 24;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
     test('updateRanges remove all', () => {
       assertHasRange(rangeA, true);
       assertHasRange(rangeB, true);
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 02fd17d..ec65ca1 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
-import {RepoName} from '../../types/common';
+import {BranchName, RepoName} from '../../types/common';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
@@ -25,6 +25,14 @@
   repo?: RepoName;
   filter?: string | null;
   offset?: number | string;
+  /**
+   * This is for creating a change from the URL and then redirecting to a file
+   * editing page.
+   */
+  createEdit?: {
+    branch: BranchName;
+    path: string;
+  };
 }
 
 export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 040ac0c..1857bad 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -66,6 +66,10 @@
   pluginLoaderToken,
 } from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {authServiceToken} from './gr-auth/gr-auth';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from './service-worker-installer';
 
 /**
  * The AppContext lazy initializator for all services
@@ -211,5 +215,14 @@
       highlightServiceToken,
       () => new HighlightService(appContext.reportingService),
     ],
+    [
+      serviceWorkerInstallerToken,
+      () =>
+        new ServiceWorkerInstaller(
+          appContext.flagsService,
+          appContext.reportingService,
+          resolver(userModelToken)
+        ),
+    ],
   ]);
 }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 9b7986b..6ad03a3 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1076,7 +1076,8 @@
     changesPerPage?: number,
     query?: string,
     offset?: 'n,z' | number,
-    options?: string
+    options?: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined> {
     const request = this.getRequestForGetChanges(
       changesPerPage,
@@ -1086,9 +1087,13 @@
     );
 
     return Promise.resolve(
-      this._restApiHelper.fetchJSON(request, true) as Promise<
-        ChangeInfo[] | undefined
-      >
+      this._restApiHelper.fetchJSON(
+        {
+          ...request,
+          errFn,
+        },
+        true
+      ) as Promise<ChangeInfo[] | undefined>
     ).then(response => {
       if (!response) {
         return;
@@ -1313,13 +1318,15 @@
   queryChangeFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    query: string
+    query: string,
+    errFn?: ErrorCallback
   ) {
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: `/files?q=${encodeURIComponent(query)}`,
       revision: patchNum,
       anonymizedEndpoint: '/files?q=*',
+      errFn,
     }) as Promise<string[] | undefined>;
   }
 
@@ -1350,22 +1357,37 @@
     >;
   }
 
-  getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
+  getChangeSuggestedReviewers(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
     return this._getChangeSuggestedGroup(
       ReviewerState.REVIEWER,
       changeNum,
-      inputVal
+      inputVal,
+      errFn
     );
   }
 
-  getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
-    return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
+  getChangeSuggestedCCs(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeSuggestedGroup(
+      ReviewerState.CC,
+      changeNum,
+      inputVal,
+      errFn
+    );
   }
 
   _getChangeSuggestedGroup(
     reviewerState: ReviewerState,
     changeNum: NumericChangeId,
-    inputVal: string
+    inputVal: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined> {
     // More suggestions may obscure content underneath in the reply dialog,
     // see issue 10793.
@@ -1381,6 +1403,7 @@
       endpoint: '/suggest_reviewers',
       params,
       reportEndpointAsIs: true,
+      errFn,
     }) as Promise<SuggestedReviewerInfo[] | undefined>;
   }
 
@@ -1468,7 +1491,8 @@
   async getRepos(
     filter: string | undefined,
     reposPerPage: number,
-    offset?: number
+    offset?: number,
+    errFn?: ErrorCallback
   ): Promise<ProjectInfoWithName[] | undefined> {
     const [isQuery, url] = this._getReposUrl(filter, reposPerPage, offset);
 
@@ -1482,11 +1506,13 @@
       return this._fetchSharedCacheURL({
         url,
         anonymizedUrl: '/projects/?*',
+        errFn,
       }) as Promise<ProjectInfoWithName[] | undefined>;
     } else {
       const result = await (this._fetchSharedCacheURL({
         url,
         anonymizedUrl: '/projects/?*',
+        errFn,
       }) as Promise<NameToProjectInfoMap | undefined>);
       if (result === undefined) return [];
       return Object.entries(result).map(([name, project]) => {
@@ -1612,7 +1638,8 @@
   getSuggestedGroups(
     inputVal: string,
     project?: RepoName,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<GroupNameToGroupInfoMap | undefined> {
     const params: QueryGroupsParams = {s: inputVal};
     if (n) {
@@ -1625,12 +1652,14 @@
       url: '/groups/',
       params,
       reportUrlAsIs: true,
+      errFn,
     }) as Promise<GroupNameToGroupInfoMap | undefined>;
   }
 
   getSuggestedRepos(
     inputVal: string,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<NameToProjectInfoMap | undefined> {
     const params = {
       m: inputVal,
@@ -1644,6 +1673,7 @@
       url: '/projects/',
       params,
       reportUrlAsIs: true,
+      errFn,
     });
   }
 
@@ -1651,7 +1681,8 @@
     inputVal: string,
     n?: number,
     canSee?: NumericChangeId,
-    filterActive?: boolean
+    filterActive?: boolean,
+    errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined> {
     const params: QueryAccountsParams = {o: 'DETAILS', q: ''};
     const queryParams = [];
@@ -1678,6 +1709,7 @@
       url: '/accounts/',
       params,
       anonymizedUrl: '/accounts/?n=*',
+      errFn,
     }) as Promise<AccountInfo[] | undefined>;
   }
 
@@ -1830,23 +1862,29 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
-  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
+  getChangesWithSimilarTopic(
+    topic: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined> {
     const query = `intopic:${escapeAndWrapSearchOperatorValue(topic)}`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
       anonymizedUrl: '/changes/intopic:*',
+      errFn,
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
   getChangesWithSimilarHashtag(
-    hashtag: string
+    hashtag: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined> {
     const query = `inhashtag:${escapeAndWrapSearchOperatorValue(hashtag)}`;
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
       params: {q: query},
       anonymizedUrl: '/changes/inhashtag:*',
+      errFn,
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
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 984efa9..b4b1afb 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
@@ -127,7 +127,8 @@
   getRepos(
     filter: string | undefined,
     reposPerPage: number,
-    offset?: number
+    offset?: number,
+    errFn?: ErrorCallback
   ): Promise<ProjectInfoWithName[] | undefined>;
 
   send(
@@ -152,11 +153,13 @@
 
   getChangeSuggestedReviewers(
     changeNum: NumericChangeId,
-    input: string
+    input: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getChangeSuggestedCCs(
     changeNum: NumericChangeId,
-    input: string
+    input: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   /**
    * Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
@@ -166,12 +169,14 @@
     input: string,
     n?: number,
     canSee?: NumericChangeId,
-    filterActive?: boolean
+    filterActive?: boolean,
+    errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
     project?: RepoName,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   /**
    * Execute a change action or revision action on a change.
@@ -267,7 +272,8 @@
   queryChangeFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    query: string
+    query: string,
+    errFn?: ErrorCallback
   ): Promise<string[] | undefined>;
 
   getRepoAccessRights(
@@ -472,7 +478,8 @@
     changesPerPage?: number,
     query?: string,
     offset?: 'n,z' | number,
-    options?: string
+    options?: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined>;
   getChangesForMultipleQueries(
     changesPerPage?: number,
@@ -515,7 +522,8 @@
 
   getSuggestedRepos(
     inputVal: string,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<NameToProjectInfoMap | undefined>;
 
   invalidateGroupsCache(): void;
@@ -652,9 +660,13 @@
       changeToExclude?: NumericChangeId;
     }
   ): Promise<ChangeInfo[] | undefined>;
-  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarTopic(
+    topic: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarHashtag(
-    hashtag: string
+    hashtag: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined>;
 
   /**
diff --git a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 3b13dbc..842dace 100644
--- a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -29,6 +29,7 @@
   GroupId,
   ReviewerState,
 } from '../../api/rest-api';
+import {throwingErrorCallback} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export interface ReviewerSuggestionsProvider {
   getSuggestions(input: string): Promise<Suggestion[]>;
@@ -52,6 +53,11 @@
     this.changes = changes;
   }
 
+  /**
+   * Requests related suggestions.
+   *
+   * If the request fails the returned promise is rejected.
+   */
   async getSuggestions(input: string): Promise<Suggestion[]> {
     if (!this.loggedIn) return [];
 
@@ -121,8 +127,16 @@
     input: string
   ): Promise<SuggestedReviewerInfo[] | undefined> {
     return this.type === ReviewerState.REVIEWER
-      ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
-      : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+      ? this.restApi.getChangeSuggestedReviewers(
+          changeNumber,
+          input,
+          throwingErrorCallback
+        )
+      : this.restApi.getChangeSuggestedCCs(
+          changeNumber,
+          input,
+          throwingErrorCallback
+        );
   }
 }
 
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index ffc5be2..e98f84a 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -14,6 +14,10 @@
 import {until} from '../utils/async-util';
 import {LifeCycle} from '../constants/reporting';
 import {ReportingService} from './gr-reporting/gr-reporting';
+import {define} from '../models/dependency';
+import {Model} from '../models/model';
+import {Observable} from 'rxjs';
+import {select} from '../utils/observable-util';
 
 /** Type of incoming messages for ServiceWorker. */
 export enum ServiceWorkerMessageType {
@@ -24,8 +28,35 @@
 
 export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
 
-export class ServiceWorkerInstaller {
-  initialized = false;
+export const serviceWorkerInstallerToken = define<ServiceWorkerInstaller>(
+  'service-worker-installer'
+);
+
+/**
+ * Service worker state:
+ * initialized - True when service worker registered and event listeners added.
+ *             - False otherwise
+ * shouldShowPrompt - True when user didn't make decision about notifications
+ *                  - False otherwise
+ */
+export interface ServiceWorkerInstallerState {
+  initialized: boolean;
+  shouldShowPrompt: boolean;
+}
+
+export class ServiceWorkerInstaller extends Model<ServiceWorkerInstallerState> {
+  readonly initialized$: Observable<Boolean | undefined> = select(
+    this.state$,
+    state => state.initialized
+  );
+
+  readonly shouldShowPrompt$: Observable<Boolean | undefined> = select(
+    this.initialized$,
+    _ => this.shouldShowPrompt()
+  );
+
+  // Internal state, it's exposed in initialized$
+  private initialized = false;
 
   account?: AccountDetailInfo;
 
@@ -36,6 +67,7 @@
     private readonly reportingService: ReportingService,
     private readonly userModel: UserModel
   ) {
+    super({initialized: false, shouldShowPrompt: false});
     if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
       return;
     }
@@ -77,12 +109,13 @@
       return;
     }
     await registerServiceWorker('/service-worker.js');
-    const permission = await Notification.requestPermission();
+    const permission = Notification.permission;
     this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
       permission,
     });
     if (this.isPermitted(permission)) this.startTriggerTimer();
     this.initialized = true;
+    this.updateState({initialized: true});
     // Assumption: service worker will send event only to 1 client.
     navigator.serviceWorker.onmessage = event => {
       if (event.data?.type === ServiceWorkerMessageType.REPORTING) {
@@ -93,6 +126,25 @@
     };
   }
 
+  // private, used in test
+  shouldShowPrompt(): boolean {
+    if (!this.initialized) return false;
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return false;
+    }
+    if (!this.areNotificationsEnabled()) return false;
+    return Notification.permission === 'default';
+  }
+
+  public async requestPermission() {
+    const permission = await Notification.requestPermission();
+    this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+      requested: true,
+      permission,
+    });
+    if (this.isPermitted(permission)) this.startTriggerTimer();
+  }
+
   areNotificationsEnabled() {
     // Push Notification developer can have notification enabled even if they
     // are disabled for this.account.
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index dac9b92..756c209 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -25,6 +25,7 @@
 import {Finalizable} from '../registry';
 import {UserModel} from '../../models/user/user-model';
 import {define} from '../../models/dependency';
+import {isCharacterLetter, isUpperCase} from '../../utils/string-util';
 
 export {Shortcut, ShortcutSection};
 
@@ -365,7 +366,10 @@
   if (binding.combo === ComboKey.V) {
     description.push('v');
   }
-  if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+  if (
+    binding.modifiers?.includes(Modifier.SHIFT_KEY) ||
+    (isCharacterLetter(binding.key) && isUpperCase(binding.key))
+  ) {
     description.push('Shift');
   }
   if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
@@ -377,6 +381,12 @@
   if (binding.modifiers?.includes(Modifier.META_KEY)) {
     description.push('Meta/Cmd');
   }
-  description.push(describeKey(binding.key));
+
+  let key = describeKey(binding.key);
+  if (isCharacterLetter(key)) {
+    key = key.toLowerCase();
+  }
+  description.push(key);
+
   return description;
 }
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 0a5e0a4..164000a 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -32,7 +32,7 @@
 
   test('getShortcut', () => {
     assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
-    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
+    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'Shift+a');
   });
 
   suite('addShortcut()', () => {
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index cc89c3c..120b0bd 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -9,6 +9,7 @@
   .gr-form-styles input {
     background-color: var(--view-background-color);
     color: var(--primary-text-color);
+    font: inherit;
   }
   .gr-form-styles select {
     background-color: var(--select-background-color);
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 0ddf130..98aaf0f 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -47,7 +47,7 @@
     "lib": [
       "dom",
       "dom.iterable",
-      "es2020",
+      "es2021",
       "webworker"
     ],
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 93a3f2a..7370c96 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -750,8 +750,6 @@
   type: string;
   _name?: string;
   _expectedType?: string;
-  _width?: number;
-  _height?: number;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 207152c..ebf9e7a 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -24,6 +24,7 @@
 import {getApprovalInfo} from './label-util';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {ParsedChangeInfo} from '../types/types';
+import {throwingErrorCallback} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
 const SUGGESTIONS_LIMIT = 15;
@@ -84,6 +85,17 @@
   );
 }
 
+export function uniqueAccountId(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index ===
+    accountArray.findIndex(other => account._account_id === other._account_id)
+  );
+}
+
 export function isDetailedAccount(account?: AccountInfo) {
   // In case ChangeInfo is requested without DetailedAccount option, the
   // reviewer entry is returned as just {_account_id: 123}
@@ -185,7 +197,13 @@
   filterActive = false
 ) {
   return restApiService
-    .getSuggestedAccounts(input, SUGGESTIONS_LIMIT, canSee, filterActive)
+    .getSuggestedAccounts(
+      input,
+      SUGGESTIONS_LIMIT,
+      canSee,
+      filterActive,
+      throwingErrorCallback
+    )
     .then(accounts => {
       if (!accounts) return [];
       const accountSuggestions = [];
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index f531698..8af5beb 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -22,6 +22,7 @@
   VotingRangeInfo,
   FixSuggestionInfo,
   FixId,
+  PatchSetNumber,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -136,6 +137,20 @@
   };
 }
 
+export function createPatchsetLevelUnsavedDraft(
+  patchNum?: PatchSetNumber,
+  message?: string,
+  unresolved?: boolean
+): UnsavedInfo {
+  return {
+    patch_set: patchNum,
+    message,
+    unresolved,
+    path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+    __unsaved: true,
+  };
+}
+
 export function createUnsavedReply(
   replyingTo: CommentInfo,
   message: string,
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 659fc20..81dcde1 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -18,6 +18,14 @@
   return s.replace(/[^a-zA-Z]+/g, '');
 }
 
+export function isCharacterLetter(ch: string): boolean {
+  return ch.length === 1 && ch.toLowerCase() !== ch.toUpperCase();
+}
+
+export function isUpperCase(ch: string): boolean {
+  return ch === ch.toUpperCase();
+}
+
 export function ordinal(n?: number): string {
   if (n === undefined) return '';
   if (n % 10 === 1 && n % 100 !== 11) return `${n}st`;
@@ -32,7 +40,7 @@
  * contain spaces and colons.
  */
 export function escapeAndWrapSearchOperatorValue(value: string): string {
-  return `"${value.replace('\\', '\\\\').replace('"', '\\"')}"`;
+  return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index c6c65b1..d6c4187 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -10,6 +10,7 @@
   ordinal,
   listForSentence,
   diffFilePaths,
+  escapeAndWrapSearchOperatorValue,
 } from './string-util';
 
 suite('string-util tests', () => {
@@ -84,4 +85,11 @@
       fileName: 'COMMIT_MSG',
     });
   });
+
+  test('escapeAndWrapSearchOperatorValue', () => {
+    assert.equal(
+      escapeAndWrapSearchOperatorValue('"value of \\: \\"something"'),
+      '"\\"value of \\\\: \\\\\\"something\\""'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index f9cc591..1c54158 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -131,9 +131,12 @@
     // User can have different service workers for different origins/hosts.
     // TODO(milutin): Check if this works properly with getBaseUrl()
     const data = {url: `${self.location.origin}${changeUrl}`};
-
-    // TODO(milutin): Add gerrit host icon
-    this.ctx.registration.showNotification(change.subject, {body, data});
+    const icon = `${self.location.origin}/favicon.ico`;
+    this.ctx.registration.showNotification(change.subject, {
+      body,
+      data,
+      icon,
+    });
     this.sendReport('notify about 1 change');
   }
 
@@ -141,7 +144,8 @@
     const title = `You are in the attention set for ${numOfChangesToNotifyAbout} changes.`;
     const dashboardUrl = createDashboardUrl({});
     const data = {url: `${self.location.origin}${dashboardUrl}`};
-    this.ctx.registration.showNotification(title, {data});
+    const icon = `${self.location.origin}/favicon.ico`;
+    this.ctx.registration.showNotification(title, {data, icon});
     this.sendReport(`notify about ${numOfChangesToNotifyAbout} changes`);
   }
 
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index cf50499..7f26ef3 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -67,18 +67,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.9.1"
+    SSHD_VERS = "2.9.2"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "9ed1a653da98a1aabe3ae092ee8310299718e914",
+        sha1 = "bac0415734519b2fe433fea196017acf7ed32660",
     )
 
     maven_jar(
         name = "sshd-sftp",
         artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
-        sha1 = "6d01cb8138e60e97e3de08e96cc5a094c8ce2cac",
+        sha1 = "7f9089c87b3b44f19998252fd3b68637e3322920",
     )
 
     maven_jar(
@@ -89,14 +89,14 @@
 
     maven_jar(
         name = "mina-core",
-        artifact = "org.apache.mina:mina-core:2.0.21",
-        sha1 = "e1a317689ecd438f54e863747e832f741ef8e092",
+        artifact = "org.apache.mina:mina-core:2.0.23",
+        sha1 = "391228b25d3a24434b205444cd262780a9ea61e7",
     )
 
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "5ab797b99630bb0c3e9ebcd8a3a6cad46408a79a",
+        sha1 = "765dced3a2b4069bb0c550e18bda057bad8de26f",
     )
 
     maven_jar(