Merge branch 'stable-3.1'

* stable-3.1:
  Acceptance BUILD: Fix plugin standalone test exec
  Bazel: Reduce size of gerrit-acceptance-framework artifact

Change-Id: I42dc463ee92f662ed6169c56670d1af2eb3c9c9f
diff --git a/.bazelignore b/.bazelignore
index 30f1613..69c04b1 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1 +1,2 @@
 eclipse-out
+node_modules
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index d333347..23ab39b 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Access Controls
 
 Access controls in Gerrit are group based.  Every user account is a
@@ -7,10 +8,10 @@
 
 To view/edit the access controls for a specific project, first
 navigate to the projects page: for example,
-https://gerrit-review.googlesource.com/admin/repos/ . Then click on
+https://gerrit-review.googlesource.com/admin/repos/[role=external,window=_blank]. Then click on
 the individual project, and then click Access. This will bring you
 to a url that looks like
-https://gerrit-review.googlesource.com/admin/repos/gerrit,access
+https://gerrit-review.googlesource.com/admin/repos/gerrit,access[role=external,window=_blank]
 
 [[system_groups]]
 == System Groups
@@ -218,7 +219,7 @@
 `^refs/heads/[a-z]{1,8}` matches all lower case branch names
 between 1 and 8 characters long.  Within a regular expression `.`
 is a wildcard matching any character, but may be escaped as `\.`.
-The link:http://www.brics.dk/automaton/[dk.brics.automaton library]
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
 is used for evaluation of regular expression access control
 rules. See the library documentation for details on this
 particular regular expression flavor. One quirk is that the
@@ -979,7 +980,7 @@
 to build and then leave a verdict somehow.
 
 As an example, the popular
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[gerrit-trigger plugin]
+link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[gerrit-trigger plugin,role=external,window=_blank]
 for Jenkins/Hudson can set labels at:
 
 * The start of a build
diff --git a/Documentation/backup.txt b/Documentation/backup.txt
index 7220c74..dd47035 100644
--- a/Documentation/backup.txt
+++ b/Documentation/backup.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Backup
 
 A Gerrit Code Review site contains data that needs to be backed up regularly.
@@ -47,7 +48,7 @@
 +
 If you have chosen to use _Elastic Search_ for indexing,
 refer to its
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html[backup documentation].
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html[backup documentation,role=external,window=_blank].
 
 [#optional-backup-cache]
 Caches::
@@ -70,7 +71,7 @@
 * public and private SSH host keys
 +
 You may consider to use the
-link:https://gerrit.googlesource.com/plugins/secure-config/[secure-config plugin]
+link:https://gerrit.googlesource.com/plugins/secure-config/[secure-config plugin,role=external,window=_blank]
 to encrypt these secrets.
 
 [#optional-backup-plugin-data]
@@ -145,7 +146,7 @@
 backup. This means read-access is still available from replica servers during
 backup, because only write operations have to be stopped to ensure consistency.
 This can be implemented using the
-link:https://gerrit.googlesource.com/plugins/readonly/[_readonly_] plugin.
+link:https://gerrit.googlesource.com/plugins/readonly/[_readonly_,role=external,window=_blank] plugin.
 
 [#cons-backup-replicate]
 === Replicate data for backup
@@ -157,9 +158,9 @@
 Replicate all git repositories to another file system using
 `git clone --mirror`,
 or the
-link:https://gerrit.googlesource.com/plugins/replication[replication plugin]
+link:https://gerrit.googlesource.com/plugins/replication[replication plugin,role=external,window=_blank]
 or the
-link:https://gerrit.googlesource.com/plugins/pull-replication[pull-replication plugin].
+link:https://gerrit.googlesource.com/plugins/pull-replication[pull-replication plugin,role=external,window=_blank].
 Best you use a filesystem supporting snapshots to create a backup archive
 of such a replica.
 
@@ -174,7 +175,7 @@
 Do not skip backing up the replica, the replica alone IS NOT a backup.
 Imagine someone deleted a project by mistake and this deletion got replicated.
 Replication of repository deletions can be switched off using the
-link:https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md[server option]
+link:https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md[server option,role=external,window=_blank]
 `remote.NAME.replicateProjectDeletions`.
 
 If you are using Gerrit replica to offload read traffic you can use one of these
@@ -197,13 +198,13 @@
 Filesystems supporting copy on write snapshots::
 +
 Use a file system supporting copy-on-write snapshots like
-link:https://btrfs.wiki.kernel.org/index.php/SysadminGuide#Snapshots[btrfs]
+link:https://btrfs.wiki.kernel.org/index.php/SysadminGuide#Snapshots[btrfs,role=external,window=_blank]
 or
-https://wiki.debian.org/ZFS#Snapshots[zfs].
+https://wiki.debian.org/ZFS#Snapshots[zfs,role=external,window=_blank].
 
 
 Other filesystems supporting snapshots::
-https://wiki.archlinux.org/index.php/LVM#Snapshots[lvm] or nfs.
+https://wiki.archlinux.org/index.php/LVM#Snapshots[lvm,role=external,window=_blank] or nfs.
 +
 Create a snapshot and then archive the snapshot to another storage.
 +
@@ -259,7 +260,7 @@
 [#backup-dr-multi-site]
 === Multi-site setup
 
-Use the https://gerrit.googlesource.com/plugins/multi-site[multi-site plugin]
+Use the https://gerrit.googlesource.com/plugins/multi-site[multi-site plugin,role=external,window=_blank]
 to install Gerrit with multiple sites installed in different datacenters
 across different regions. This ensures that in case of a severe problem with
 one of the sites, the other sites can still serve your repositories.
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index 49d5c17..2b6d7af 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = commit-msg Hook
 
 
@@ -86,8 +87,8 @@
 
 
 * link:user-changeid.html[Change-Id Lines]
-* link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[git-commit(1)]
-* link:http://www.kernel.org/pub/software/scm/git/docs/githooks.html[githooks(5)]
+* link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[git-commit(1),role=external,window=_blank]
+* link:http://www.kernel.org/pub/software/scm/git/docs/githooks.html[githooks(5),role=external,window=_blank]
 
 == IMPLEMENTATION
 
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index edb54b5..c991a2a 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -178,6 +178,9 @@
 link:cmd-set-account.html[gerrit set-account]::
 	Change an account's settings.
 
+link:cmd-sequence-show.html[gerrit sequence show]::
+        Display current sequence value.
+
 link:cmd-set-members.html[gerrit set-members]::
 	Set group members.
 
diff --git a/Documentation/cmd-sequence-set.txt b/Documentation/cmd-sequence-set.txt
new file mode 100644
index 0000000..9023ceb
--- /dev/null
+++ b/Documentation/cmd-sequence-set.txt
@@ -0,0 +1,54 @@
+= gerrit sequence set
+
+== NAME
+gerrit sequence set - Set new sequence value.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit sequence set_ <NAME> <VALUE>
+--
+
+== DESCRIPTION
+Gerrit maintains the generation of the next available sequence numbers for
+account, group and change entities. The sequences are stored as UTF-8 text in
+a blob pointed to by the `refs/sequences/accounts`, `refs/sequences/groups`
+and `refs/sequences/changes` refs. Those refs are stored in `All-Users` and
+`All-Projects` git repositories correspondingly.
+
+This command allows to set a new sequence value for those sequences.
+
+The link:cmd-sequence-show.html[sequence-show] command displays current
+sequence value.
+
+== ACCESS
+Caller must be a member of the privileged 'Administrators' group.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+<NAME>::
+  Sequence name to set a new value for.
+  Currently supported values:
+    * accounts
+    * groups
+    * changes
+
+<VALUE>::
+  New value for the sequence.
+
+== EXAMPLES
+Set a new value for the 'changes' sequence:
+
+----
+$ ssh -p 29418 review.example.com gerrit sequence set changes 42
+The value for the changes sequence was set to 42.
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-sequence-show.txt b/Documentation/cmd-sequence-show.txt
new file mode 100644
index 0000000..6b9371f
--- /dev/null
+++ b/Documentation/cmd-sequence-show.txt
@@ -0,0 +1,51 @@
+= gerrit sequence show
+
+== NAME
+gerrit sequence show - Display current sequence value.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit sequence show_ <NAME>
+--
+
+== DESCRIPTION
+Gerrit maintains the generation of the next available sequence numbers for
+account, group and change entities. The sequences are stored as UTF-8 text in
+a blob pointed to by the `refs/sequences/accounts`, `refs/sequences/groups`
+and `refs/sequences/changes` refs. Those refs are stored in `All-Users` and
+`All-Projects` git repositories correspondingly.
+
+This command allows to display the current sequence value for those sequences.
+
+The link:cmd-sequence-set.html[sequence-set] command allows to set a new
+sequence value.
+
+== ACCESS
+Caller must be a member of the privileged 'Administrators' group.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== OPTIONS
+<NAME>::
+  Sequence name to show the current value for.
+  Currently supported values:
+    * accounts
+    * groups
+    * changes
+
+== EXAMPLES
+Display the current value for the 'changes' sequence:
+
+----
+$ ssh -p 29418 review.example.com gerrit sequence show changes
+42
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/concept-refs-for-namespace.txt b/Documentation/concept-refs-for-namespace.txt
index c8776ae..003a106 100644
--- a/Documentation/concept-refs-for-namespace.txt
+++ b/Documentation/concept-refs-for-namespace.txt
@@ -1,7 +1,8 @@
+:linkattrs:
 = The refs/for namespace
 
 When pushing a new or updated commit to Gerrit, you push that commit using a
-link:https://www.kernel.org/pub/software/scm/git/docs/gitglossary.html#def_ref[reference],
+link:https://www.kernel.org/pub/software/scm/git/docs/gitglossary.html#def_ref[reference,role=external,window=_blank],
 in the `refs/for` namespace. This reference must also define
 the target branch, such as `refs/for/[BRANCH_NAME]`.
 
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 90150b1..b1ce1ce 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Accounts
 
 == Overview
@@ -143,7 +144,7 @@
 
 In addition it contains an
 link:https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys[
-authorized_keys] file with the link:#ssh-keys[SSH keys] of the account.
+authorized_keys,role=external,window=_blank] file with the link:#ssh-keys[SSH keys] of the account.
 
 [[account-properties]]
 === Account Properties
@@ -256,7 +257,7 @@
 SSH keys are stored in the user branch in an `authorized_keys` file,
 which is the
 link:https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys[
-standard OpenSSH file format] for storing SSH keys:
+standard OpenSSH file format,role=external,window=_blank] for storing SSH keys:
 
 ----
 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqSuJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5Tw== john.doe@example.com
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index cf255f3..82cb3f9 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Configuration
 
 == File `etc/gerrit.config`
@@ -174,7 +175,7 @@
 +
 The default setting.  Gerrit uses any valid OpenID
 provider chosen by the end-user.  For more information see
-http://openid.net/[openid.net].
+http://openid.net/[openid.net,role=external,window=_blank].
 +
 * `OpenID_SSO`
 +
@@ -286,7 +287,7 @@
 +
 Patterns may be either a
 link:http://download.oracle.com/javase/6/docs/api/java/util/regex/Pattern.html[standard
-Java regular expression (java.util.regex)] (start with `^` and
+Java regular expression (java.util.regex),role=external,window=_blank] (start with `^` and
 end with `$`) or be a simple prefix (any other string).
 +
 By default, the list contains two values, `http://` and `https://`,
@@ -304,7 +305,7 @@
 +
 Patterns may be either a
 link:http://download.oracle.com/javase/6/docs/api/java/util/regex/Pattern.html[standard
-Java regular expression (java.util.regex)] (start with `^` and
+Java regular expression (java.util.regex),role=external,window=_blank] (start with `^` and
 end with `$`) or be a simple prefix (any other string).
 +
 By default, the list contains two values, `http://` and `https://`,
@@ -695,7 +696,7 @@
 +
 Technically the H2 cache size is configured using the CACHE_SIZE parameter in
 the H2 JDBC connection URL, as described
-link:http://www.h2database.com/html/features.html#cache_settings[here]
+link:http://www.h2database.com/html/features.html#cache_settings[here,role=external,window=_blank]
 +
 Default is unset, using up to half of the available memory.
 +
@@ -708,7 +709,7 @@
 If set to true, enable H2 autoserver mode for the H2-backed persistent cache
 databases.
 +
-See link:http://www.h2database.com/html/features.html#auto_mixed_mode[here]
+See link:http://www.h2database.com/html/features.html#auto_mixed_mode[here,role=external,window=_blank]
 for detail.
 +
 Default is false.
@@ -1170,15 +1171,6 @@
 +
 Default is `ALL`.
 
-[[change.api.excludeMergeableInChangeInfo]]change.api.excludeMergeableInChangeInfo::
-+
-If true, the mergeability bit in
-link:rest-api-changes.html#change-info[ChangeInfo] will never be set. It can
-be requested separately through the
-link:rest-api-changes.html#get-mergeable[get-mergeable] endpoint.
-+
-Default is false.
-
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
 When reviewing diff commits, the left-hand side shows the output of the
@@ -1194,6 +1186,15 @@
 +
 Default is true.
 
+[[change.commentSizeLimit]]change.commentSizeLimit::
++
+Maximum allowed size in characters of a regular (non-robot) comment. Comments
+which exceed this size will be rejected. Size computation is approximate and may
+be off by roughly 1%. Common unit suffixes of 'k', 'm', or 'g' are supported.
+The value must be positive.
++
+The default limit is 16kB.
+
 [[change.disablePrivateChanges]]change.disablePrivateChanges::
 +
 If set to true, users are not allowed to create private changes.
@@ -1211,6 +1212,13 @@
 +
 By default 500.
 
+[[change.maxComments]]change.maxComments::
++
+Maximum number of comments (regular plus robot) allowed per change. Additional
+comments are rejected.
++
+By default 5,000.
+
 [[change.maxUpdates]]change.maxUpdates::
 +
 Maximum number of updates to a change. Counts only updates to the main NoteDb
@@ -1222,14 +1230,43 @@
 high CPU usage, memory pressure, persistent cache bloat, and other problems.
 +
 The following operations are allowed even when a change is at the limit:
+
 * Abandon
 * Submit
 * Submit by push with `%submit`
 * Auto-close by pushing directly to the branch
 * Fix with link:rest-api-changes.html#fix-input[`expect_merged_as`]
-+
+
 By default 1000.
 
+[[change.mergeabilityComputationBehavior]]change.mergeabilityComputationBehavior::
++
+This setting determines when Gerrit computes if a change is mergeable or not.
+This computation is expensive, especially when the repository is large or when
+there are many open changes.
++
+This config can have the following states:
++
+* `API_REF_UPDATED_AND_CHANGE_REINDEX`: Gerrit indexes `mergeability` enabling
+  the `is:mergeable` predicate in change search and allowing fast retrieval of
+  this bit in query responses. Gerrit will always serve `mergeable` in
+  link:rest-api-changes.html#change-info[ChangeInfo] objects.
+  Gerrit will reindex all open changes when the target ref advances (expensive).
+* `REF_UPDATED_AND_CHANGE_REINDEX`: Gerrit indexes `mergeability` enabling the
+  `is:mergeable` predicate in change search and allowing fast retrieval of this
+  bit in query responses. Gerrit will never serve `mergeable` in
+  link:rest-api-changes.html#change-info[ChangeInfo]
+  objects. This state can be a final state for instances that only want to
+  optimize the read path, but not the write path for speed or serve as an
+  intermediary step for instances that want to optimize both and need to migrate
+  callers of their API.
+  Gerrit will reindex all open changes when the target ref advances (expensive).
+* `NEVER`: Gerrit does not index `mergeable`, so `is:mergeable` is disabled as
+  query operator. Gerrit does not serve `mergeable` in
+  link:rest-api-changes.html#change-info[ChangeInfo].
+
+Default is `REF_UPDATED_AND_CHANGE_REINDEX`.
+
 [[change.move]]change.move::
 +
 Whether the link:rest-api-changes.html#move-change[Move Change] REST
@@ -1264,11 +1301,10 @@
 
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
-Maximum allowed size of a robot comment that will be accepted. Robot comments
-which exceed the indicated size will be rejected on addition. The specified
-value is interpreted as the maximum size in bytes of the JSON representation of
-the robot comment. Common unit suffixes of 'k', 'm', or 'g' are supported.
-Zero or negative values allow robot comments of unlimited size.
+Maximum allowed size in characters of a robot comment. Robot comments which
+exceed this size will be rejected on addition. Size computation is approximate
+and may be off by roughly 1%. Common unit suffixes of 'k', 'm', or 'g' are
+supported. Zero or negative values allow robot comments of unlimited size.
 +
 The default limit is 1024kB.
 
@@ -1392,9 +1428,16 @@
 
 [[changeCleanup.abandonIfMergeable]]changeCleanup.abandonIfMergeable::
 +
-Whether changes which are mergeable should be auto-abandoned.
+Whether changes which are mergeable should be auto-abandoned. When set
+to `false`, `-is:mergeable` is appended to the query used to find
+the changes to auto-abandon.
 +
-By default `true`.
+By default `true`, meaning mergeable changes are auto-abandoned.
++
+If
+link:#change.mergeabilityComputationBehavior[`change.mergeabilityComputationBehavior`]
+is set to `NEVER`, setting this option to `false` has no effect and it behaves
+as though it were set to `true`.
 
 [[changeCleanup.cleanupAccountPatchReview]]changeCleanup.cleanupAccountPatchReview::
 +
@@ -1708,7 +1751,7 @@
 +
 As explained in this
 link:http://codicesoftware.blogspot.com/2011/09/merge-recursive-strategy.html[
-blog], the recursive merge produces better results if the two commits
+blog,role=external,window=_blank], the recursive merge produces better results if the two commits
 that are merged have more than one common predecessor.
 +
 Default is true.
@@ -2241,9 +2284,9 @@
 branch names. Some web servers, such as Tomcat, reject this hexadecimal
 encoding in the URL.
 +
-Some alternative gitweb services, such as link:http://gitblit.com[Gitblit],
+Some alternative gitweb services, such as link:http://gitblit.com[Gitblit,role=external,window=_blank],
 allow using an alternative path separator character. In Gitblit, this can be
-configured through the property link:http://gitblit.com/properties.html[web.forwardSlashCharacter].
+configured through the property link:http://gitblit.com/properties.html[web.forwardSlashCharacter,role=external,window=_blank].
 In Gerrit, the alternative path separator can be configured correspondingly
 using the property `gitweb.pathSeparator`.
 +
@@ -2393,7 +2436,7 @@
 Like `http://`, but additional header parsing features are
 enabled to honor `X-Forwarded-For`, `X-Forwarded-Host` and
 `X-Forwarded-Server`.  These headers are typically set by Apache's
-link:https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#x-headers[mod_proxy].
+link:https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#x-headers[mod_proxy,role=external,window=_blank].
 +
 [NOTE]
 --
@@ -2668,6 +2711,28 @@
 	filterClass = org.anyorg.MySecureIPFilter
 ----
 
+[[filterClass.className.initParam]]filterClass.<className>.initParam::
++
+Gerrit supports customized pluggable HTTP filters as `filterClass`. This
+option allows to pass extra initialization parameters to the filter. It
+allows for multiple key/value pairs to be passed in this pattern:
++
+----
+initParam = <key>=<value>
+----
+For a comprehensive example:
++
+----
+[httpd]
+	filterClass = org.anyorg.AFilter
+	filterClass = org.anyorg.BFilter
+[filterClass "org.anyorg.AFilter"]
+	key1 = value1
+	key2 = value2
+[filterClass "org.anyorg.BFilter"]
+	key3 = value3
+----
+
 [[httpd.idleTimeout]]httpd.idleTimeout::
 +
 Maximum idle time for a connection, which roughly translates to the
@@ -2722,7 +2787,7 @@
 +
 * `ELASTICSEARCH` look into link:#elasticsearch[Elasticsearch section]
 +
-An link:https://www.elastic.co/products/elasticsearch[Elasticsearch] index is
+An link:https://www.elastic.co/products/elasticsearch[Elasticsearch,role=external,window=_blank] index is
 used. Refer to the link:#elasticsearch[Elasticsearch section] for further
 configuration details.
 
@@ -2793,19 +2858,6 @@
 +
 Defaults to 1024.
 
-[[index.reindexAfterRefUpdate]]index.reindexAfterRefUpdate::
-+
-Whether to reindex all affected open changes after a ref is updated. This
-includes reindexing all open changes to recompute the "mergeable" bit every time
-the destination branch moves, as well as reindexing changes to take into account
-new project configuration (e.g. label definitions).
-+
-Leaving this enabled may result in fresher results, but may cause performance
-problems if there are lots of open changes on a project whose branches advance
-frequently.
-+
-Defaults to true.
-
 [[index.autoReindexIfStale]]index.autoReindexIfStale::
 +
 Whether to automatically check if a document became stale in the index
@@ -2883,7 +2935,7 @@
 Determines the amount of RAM that may be used for buffering added documents
 and deletions before they are flushed to the index.  See the
 link:http://lucene.apache.org/core/4_6_0/core/org/apache/lucene/index/LiveIndexWriterConfig.html#setRAMBufferSizeMB(double)[
-Lucene documentation] for further details.
+Lucene documentation,role=external,window=_blank] for further details.
 +
 Defaults to 16M.
 
@@ -2893,7 +2945,7 @@
 in-memory documents are flushed to the index. Large values generally
 give faster indexing.  See the
 link:http://lucene.apache.org/core/4_6_0/core/org/apache/lucene/index/LiveIndexWriterConfig.html#setMaxBufferedDocs(int)[
-Lucene documentation] for further details.
+Lucene documentation,role=external,window=_blank] for further details.
 +
 Defaults to -1, meaning no maximum is set and the writer will flush
 according to RAM usage.
@@ -2926,7 +2978,7 @@
 completed.  Note that Lucene will only run the smallest maxThreadCount merges
 at a time. See the
 link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#setDefaultMaxMergesAndThreads(boolean)[
-Lucene documentation] for further details.
+Lucene documentation,role=external,window=_blank] for further details.
 +
 Defaults to -1 for (auto detection).
 
@@ -2936,13 +2988,13 @@
 Determines the max number of simultaneous Lucene merge threads that should be running at
 once. This must be less than or equal to maxMergeCount. See the
 link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#setDefaultMaxMergesAndThreads(boolean)[
-Lucene documentation] for further details.
+Lucene documentation,role=external,window=_blank] for further details.
 +
 For further details on Lucene index configuration (auto detection) which
 affects maxThreadCount and maxMergeCount settings.
 See the
 link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#AUTO_DETECT_MERGES_AND_THREADS[
-Lucene documentation]
+Lucene documentation,role=external,window=_blank]
 +
 Defaults to -1 for (auto detection).
 
@@ -2953,7 +3005,7 @@
 on is used to adaptively rate limit writes bytes/sec to the minimal rate necessary
 so merges do not fall behind. See the
 link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#enableAutoIOThrottle()[
-Lucene documentation] for further details.
+Lucene documentation,role=external,window=_blank] for further details.
 +
 Defaults to true (throttling enabled).
 
@@ -2983,7 +3035,7 @@
 
 WARNING: Support for Elasticsearch is still experimental and is not recommended
 for production use. For compatibility information, please refer to the
-link:https://www.gerritcodereview.com/elasticsearch.html[project homepage].
+link:https://www.gerritcodereview.com/elasticsearch.html[project homepage,role=external,window=_blank].
 
 When using Elasticsearch version 5.6, the open and closed changes are
 indexed in a single index, separated into types `open_changes` and `closed_changes`
@@ -3018,7 +3070,7 @@
 +
 Sets the number of shards to use per index. Refer to the
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
-Elasticsearch documentation] for details.
+Elasticsearch documentation,role=external,window=_blank] for details.
 +
 Defaults to 5 for Elasticsearch versions 5 and 6, and to 1 starting with Elasticsearch 7.
 
@@ -3026,7 +3078,7 @@
 +
 Sets the number of replicas to use per index. Refer to the
 link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
-Elasticsearch documentation] for details.
+Elasticsearch documentation,role=external,window=_blank] for details.
 +
 Defaults to 1.
 
@@ -3037,12 +3089,12 @@
 
 For further information about Elasticsearch security, please refer to the documentation:
 
-* link:https://www.elastic.co/guide/en/x-pack/5.6/security-getting-started.html[Elasticsearch 5.6]
-* link:https://www.elastic.co/guide/en/x-pack/6.2/security-getting-started.html[Elasticsearch 6.2]
-* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.3/security-getting-started.html[Elasticsearch 6.3]
-* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.4/security-getting-started.html[Elasticsearch 6.4]
-* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.5/security-getting-started.html[Elasticsearch 6.5]
-* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.6/security-getting-started.html[Elasticsearch 6.6]
+* link:https://www.elastic.co/guide/en/x-pack/5.6/security-getting-started.html[Elasticsearch 5.6,role=external,window=_blank]
+* link:https://www.elastic.co/guide/en/x-pack/6.2/security-getting-started.html[Elasticsearch 6.2,role=external,window=_blank]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.3/security-getting-started.html[Elasticsearch 6.3,role=external,window=_blank]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.4/security-getting-started.html[Elasticsearch 6.4,role=external,window=_blank]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.5/security-getting-started.html[Elasticsearch 6.5,role=external,window=_blank]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.6/security-getting-started.html[Elasticsearch 6.6,role=external,window=_blank]
 
 [[elasticsearch.username]]elasticsearch.username::
 +
@@ -3069,7 +3121,7 @@
 events.
 +
 This can be set to the set of minimal options that consumers of Gerrit's
-events need. A minimal set would be (`SKIP_MERGEABLE`,`SKIP_DIFFSTAT`).
+events need. A minimal set would be (`SKIP_DIFFSTAT`).
 +
 Every option that gets added here will have a performance impact. The
 general recommendation is therefore to set this to a minimal set of
@@ -3093,8 +3145,8 @@
 the parameters introduced here.  Suitable defaults for most
 parameters are automatically guessed based on the type of server
 detected during startup.  The guessed defaults support
-link:http://www.ietf.org/rfc/rfc2307.txt[RFC 2307], Active
-Directory and link:https://www.freeipa.org[FreeIPA].
+link:http://www.ietf.org/rfc/rfc2307.txt[RFC 2307,role=external,window=_blank], Active
+Directory and link:https://www.freeipa.org[FreeIPA,role=external,window=_blank].
 
 ----
 [ldap]
@@ -3448,8 +3500,8 @@
 garbage collected), the connection is returned to the pool for future use.
 +
 For details, see link:http://docs.oracle.com/javase/tutorial/jndi/ldap/pool.html[
-LDAP connection management (Pool)] and link:http://docs.oracle.com/javase/tutorial/jndi/ldap/config.html[
-LDAP connection management (Configuration)]
+LDAP connection management (Pool),role=external,window=_blank] and link:http://docs.oracle.com/javase/tutorial/jndi/ldap/config.html[
+LDAP connection management (Configuration),role=external,window=_blank]
 +
 By default, false.
 
@@ -3468,7 +3520,7 @@
 ldap.useConnectionPooling] configuration property to `true`, the connection pool
 can be configured using JVM system properties as explained in the
 link:http://docs.oracle.com/javase/7/docs/technotes/guides/jndi/jndi-ldap.html#POOL[
-Java SE Documentation].
+Java SE Documentation,role=external,window=_blank].
 
 For standalone Gerrit (running with the embedded Jetty), JVM system properties
 are specified in the link:#container[container section]:
@@ -3486,7 +3538,7 @@
 +
 The name of a plugin which serves the
 link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
-LFS protocol] on the `<project-name>/info/lfs/objects/batch` endpoint. When
+LFS protocol,role=external,window=_blank] on the `<project-name>/info/lfs/objects/batch` endpoint. When
 not configured Gerrit will respond with `501 Not Implemented` on LFS protocol
 requests.
 +
@@ -3596,6 +3648,39 @@
 +
 Default is false.
 
+[[operator-alias]]
+=== Section operator alias
+
+Operator aliasing allows global aliases to be defined for query operators.
+Currently only change queries are supported. The alias name is the git
+config key name, and the operator being aliased is the git config value.
+
+For example:
+
+----
+[operator-alias "change"]
+  oldage = age
+  number = change
+----
+
+This section is particularly useful to alias operator names which may be
+long and clunky because they include a plugin name in them to a shorter
+name without the plugin name.
+
+Aliases are resolved dynamically at invocation time to any currently
+loaded versions of plugins. If the alias points to an operator provided
+by a plugin which is not currently loaded, or the plugin does not define
+the operator, then "unsupported operator" is returned to the user.
+
+Aliases will override existing operators. In the case of multiple aliases
+with the same name, the last one defined will be used.
+
+When the target of an alias doesn't exist, the operator with the name
+of the alias will be used (if present). This enables an admin to config
+the system to override a core operator with an operator provided by a
+plugin when present and otherwise fall back to the operator provided by
+core.
+
 [[pack]]
 === Section pack
 
@@ -3750,6 +3835,19 @@
 +
 Default is true.
 
+[[receive.enableInMemoryRefCache]]receive.enableInMemoryRefCache::
++
+If true, Gerrit will cache all refs advertised during push in memory and
+base later receive operations on that cache.
++
+Turning this cache off is considered experimental.
++
+This cache provides value when the ref database is slow and/or does not
+offer an inverse lookup of object ID to ref name. When RefTable is used,
+this cache can be turned off (experimental) to get speed improvements.
++
+Default is true.
+
 [[receive.enableSignedPush]]receive.enableSignedPush::
 +
 If true, server-side signed push validation is enabled.
@@ -3851,7 +3949,7 @@
 +
 Trust signatures can be added to a key using the `tsign` command to
 link:https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Key-Management.html[
-`gpg --edit-key`], after which the signed key should be re-uploaded.
+`gpg --edit-key`,role=external,window=_blank], after which the signed key should be re-uploaded.
 +
 If no keys are specified, web-of-trust checks are disabled. This is the
 default behavior.
@@ -4304,7 +4402,7 @@
 [[sendemail.allowTLD]]sendemail.allowTLD::
 +
 List of custom TLDs to allow sending emails to in addition to those specified
-in the link:http://data.iana.org/TLD/[IANA list].
+in the link:http://data.iana.org/TLD/[IANA list,role=external,window=_blank].
 +
 Defaults to an empty list, meaning no additional TLDs are allowed.
 
@@ -4706,6 +4804,15 @@
 used for suggesting accounts when adding members to a group.
 +
 By default 0.
+[[suggest.relevantChanges]]suggest.relevantChanges::
++
+When suggesting reviewers, we go over recent changes of the user, and
+give priority to users that are present as reviewers in any of those
+changes. The number of changes we go over is `sugggest.relevantChanges`.
++
+By default 50. This nubmer is a tradeoff between speed and accuracy.
+A high number would be accurate but slow, and a low number would be
+fast but inaccurate.
 
 [[tracing]]
 === Section tracing
@@ -4814,7 +4921,7 @@
 [[trackingid.name.match]]trackingid.<name>.match::
 +
 A link:http://download.oracle.com/javase/6/docs/api/java/util/regex/Pattern.html[standard
-Java regular expression (java.util.regex)] used to match the
+Java regular expression (java.util.regex),role=external,window=_blank] used to match the
 external tracking id part of the footer line. The match can
 result in several entries in the DB.  If grouping is used in the
 regex the first group will be interpreted as the tracking id.
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index d49acfee..46c9ced 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 == Gitweb Integration
 
 Gerrit Code Review can manage and generate hyperlinks to gitweb,
@@ -167,21 +168,21 @@
 Instructions are available for installing the gitweb module distributed with
 MsysGit:
 
-link:https://github.com/msysgit/msysgit/wiki/GitWeb[GitWeb]
+link:https://github.com/msysgit/msysgit/wiki/GitWeb[GitWeb,role=external,window=_blank]
 
 If you don't have Apache installed, you can download the appropriate build for
 Windows from link:http://www.apachelounge.com/download[apachelounge.org].
 
 After you have installed Apache, you will want to create a link:http://httpd.apache.org/docs/2.0/platform/windows.html#winsvc[new service user
-account] to use with Apache.
+account,role=external,window=_blank] to use with Apache.
 
 If you're still having difficulty setting up permissions, you may find this
 tech note useful for configuring Apache Service to run under another account.
-You must grant the new account link:http://technet.microsoft.com/en-us/library/cc794944(WS.10).aspx["run as service"] permission:
+You must grant the new account link:http://technet.microsoft.com/en-us/library/cc794944(WS.10).aspx["run as service",role=external,window=_blank] permission:
 
 The gitweb version in msysgit is missing several important and required
 perl modules, including CGI.pm. The perl included with the msysgit distro 1.7.8
-is broken.. The link:http://groups.google.com/group/msysgit/browse_thread/thread/ba3501f1f0ed95af[unicore folder is missing along with utf8_heavy.pl and CGI.pm]. You can
+is broken.. The link:http://groups.google.com/group/msysgit/browse_thread/thread/ba3501f1f0ed95af[unicore folder is missing along with utf8_heavy.pl and CGI.pm,role=external,window=_blank]. You can
 verify by checking for perl modules. From an msys console, execute the
 following to check:
 
@@ -202,7 +203,7 @@
 If you're missing CGI.pm, you'll have to deploy the module to the msys
 environment: You will have to retrieve them from the 5.8.8 distro on :
 
-http://strawberryperl.com/releases.html
+http://strawberryperl.com/releases.html[role=external,window=_blank]
 
 File: strawberry-perl-5.8.8.3.zip
 
@@ -272,7 +273,7 @@
 === SEE ALSO
 
 * link:config-gerrit.html#gitweb[Section gitweb]
-* link:http://git.zx2c4.com/cgit/about/[cgit]
+* link:http://git.zx2c4.com/cgit/about/[cgit,role=external,window=_blank]
 
 GERRIT
 ------
diff --git a/Documentation/config-hooks.txt b/Documentation/config-hooks.txt
index 835ec11..7d8112c 100644
--- a/Documentation/config-hooks.txt
+++ b/Documentation/config-hooks.txt
@@ -1,9 +1,10 @@
+:linkattrs:
 = Gerrit Code Review - Hooks
 
 Gerrit does not run any of the standard git hooks in the repositories
 it works with, but it does have its own hook mechanism included via
 the link:https://gerrit-review.googlesource.com/admin/repos/plugins/hooks[
-hooks plugin].
+hooks plugin,role=external,window=_blank].
 
 GERRIT
 ------
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 7d46e26..5d22c0b 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -1,6 +1,7 @@
+:linkattrs:
 = Gerrit Code Review - Mail Templates
 
-Gerrit uses link:https://developers.google.com/closure/templates/[Closure Templates]
+Gerrit uses link:https://developers.google.com/closure/templates/[Closure Templates,role=external,window=_blank]
 (Soy) for the bulk of the standard mails it sends out.
 There are builtin default templates which are used if they are not overridden.
 These defaults are also provided as examples so that administrators may copy
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 464611d..36276ef 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Plugins
 
 The Gerrit server functionality can be extended by installing plugins.
@@ -25,10 +26,10 @@
 
 If you want to share your plugin under the link:licenses.html#Apache2_0[
 Apache License 2.0] you can host your plugin development on the
-link:https://gerrit-review.googlesource.com[gerrit-review] Gerrit
+link:https://gerrit-review.googlesource.com[gerrit-review,role=external,window=_blank] Gerrit
 Server. You can request the creation of a new Project by email
 to the link:https://groups.google.com/forum/#!forum/repo-discuss[Gerrit
-mailing list]. You would be assigned as project owner of the new plugin
+mailing list,role=external,window=_blank]. You would be assigned as project owner of the new plugin
 project so that you can submit changes on your own. It is the
 responsibility of the project owner to maintain the plugin, e.g. to
 make sure that it works with new Gerrit versions and to create stable
@@ -37,11 +38,9 @@
 [[core-plugins]]
 == Core Plugins
 
-Core plugins are packaged within the Gerrit war file and can easily be
-installed during the link:pgm-init.html[Gerrit initialization].
-
-The core plugins are developed and maintained by the Gerrit maintainers
-and the Gerrit community.
+link:dev-core-plugins.html[Core plugins] are packaged within the Gerrit
+war file and can easily be installed during the link:pgm-init.html[
+Gerrit initialization].
 
 Note that the documentation and configuration links in the list below are
 to the plugins' master branch. Please refer to the appropriate branch or
@@ -53,7 +52,7 @@
 CodeMirror plugin for polygerrit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/codemirror-editor[
-Project] |
+Project,role=external,window=_blank] |
 
 [[commit-message-length-validator]]
 === commit-message-length-validator
@@ -63,11 +62,11 @@
 lengths are exceeded.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/commit-message-length-validator[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[delete-project]]
 === delete-project
@@ -75,11 +74,11 @@
 Provides the ability to delete a project.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/delete-project/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/delete-project/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[download-commands]]
 === download-commands
@@ -88,11 +87,11 @@
 download schemes (for downloading via different network protocols).
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/download-commands/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/download-commands/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[gitiles]]
 === gitiles
@@ -100,7 +99,7 @@
 Plugin running Gitiles alongside a Gerrit server.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/gitiles[
-Project]
+Project,role=external,window=_blank]
 
 [[hooks]]
 === hooks
@@ -108,11 +107,11 @@
 This plugin runs server-side hooks on events.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/hooks[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[plugin-manager]]
 === plugin-manager
@@ -122,11 +121,11 @@
 this can be changed per plugin configuration.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/plugin-manager[
-Project]
+Project,role=external,window=_blank]
 link:https://gerrit.googlesource.com/plugins/plugin-manager/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 link:https://gerrit.googlesource.com/plugins/plugin-manager/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[replication]]
 === replication
@@ -137,11 +136,11 @@
 backups, or a load-balanced public mirror farm.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/replication[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/replication/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/replication/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[reviewnotes]]
 === reviewnotes
@@ -150,9 +149,9 @@
 branch.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewnotes[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reviewnotes/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[singleusergroup]]
 === singleusergroup
@@ -167,11 +166,11 @@
 This plugin allows to propagate Gerrit events to remote http endpoints.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/webhooks[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/webhooks/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/webhooks/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[other-plugins]]
 == Other Plugins
@@ -185,12 +184,12 @@
 there is one public service that offers the download of pre-built
 plugin jars:
 
-* link:https://gerrit-ci.gerritforge.com[CI Server from GerritForge]
+* link:https://gerrit-ci.gerritforge.com[CI Server from GerritForge,role=external,window=_blank]
 
 The following list gives an overview of available plugins, but the
 list may not be complete. You may discover more plugins on
 link:https://gerrit-review.googlesource.com/admin/repos/?filter=plugins%252F[
-gerrit-review].
+gerrit-review,role=external,window=_blank].
 
 Note that the documentation and configuration links in the list below are
 to the plugins' master branch. Please refer to the appropriate branch for
@@ -207,9 +206,9 @@
 project/account.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/admin-console[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/admin-console/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[analytics]]
 === analytics
@@ -220,8 +219,8 @@
 archived and processed with popular BigData transformation tools such
 Apache Spark or published and visualized in dashboards.
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/analytics[Project] |
-link:https://gerrit.googlesource.com/plugins/analytics/+doc/master/README.md[Documentation]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/analytics[Project,role=external,window=_blank] |
+link:https://gerrit.googlesource.com/plugins/analytics/+doc/master/README.md[Documentation,role=external,window=_blank]
 
 [[avatars-external]]
 === avatars-external
@@ -230,11 +229,11 @@
 from.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/avatars-external[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[avatars-gravatar]]
 === avatars-gravatar
@@ -242,7 +241,7 @@
 Plugin to display user icons from Gravatar.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/avatars-gravatar[
-Project]
+Project,role=external,window=_blank]
 
 [[branch-network]]
 === branch-network
@@ -252,11 +251,11 @@
 "project link" in a gitweb configuration.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/branch-network[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/branch-network/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/branch-network/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[changemessage]]
 === changemessage
@@ -264,11 +263,11 @@
 This plugin allows to display a static info message on the change screen.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/changemessage[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/about.md[
-Plugin Documentation] |
+Plugin Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[checks]]
 === checks
@@ -277,9 +276,9 @@
 CI systems with Gerrit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/checks[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/checks/+doc/master/README.md[
-Plugin Documentation]
+Plugin Documentation,role=external,window=_blank]
 
 [[egit]]
 === egit
@@ -291,9 +290,9 @@
 downloading a Gerrit change from within EGit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/egit[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/egit/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[emoticons]]
 === emoticons
@@ -301,11 +300,11 @@
 This plugin allows users to see emoticons in comments as images.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/emoticons[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/emoticons/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/emoticons/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[find-owners]]
 === find-owners
@@ -314,9 +313,9 @@
 (2) Prolog predicates to make sure that a CL is submittable
 only with owner Code-Review +1 votes.
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/find-owners[Project] |
-link:https://gerrit.googlesource.com/plugins/find-owners/+doc/master/src/main/resources/Documentation/about.md[Documentation] |
-link:https://gerrit.googlesource.com/plugins/find-owners/+doc/master/src/main/resources/Documentation/config.md[Configuration]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/find-owners[Project,role=external,window=_blank] |
+link:https://gerrit.googlesource.com/plugins/find-owners/+doc/master/src/main/resources/Documentation/about.md[Documentation,role=external,window=_blank] |
+link:https://gerrit.googlesource.com/plugins/find-owners/+doc/master/src/main/resources/Documentation/config.md[Configuration,role=external,window=_blank]
 
 [[gitblit]]
 === gitblit
@@ -324,7 +323,7 @@
 GitBlit code-viewer plugin with SSO and Security Access Control.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/gitblit[
-Project]
+Project,role=external,window=_blank]
 
 [[github]]
 === github
@@ -332,7 +331,7 @@
 Plugin to integrate with GitHub: replication, pull-request to Change-Sets
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/github[
-Project]
+Project,role=external,window=_blank]
 
 [[healthcheck]]
 === healthcheck
@@ -348,11 +347,11 @@
 Gerrit internal metrics and can be published to dashboards.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/healthcheck[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/healthcheck/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/healthcheck/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[imagare]]
 === imagare
@@ -360,11 +359,11 @@
 The imagare plugin allows Gerrit users to upload and share images.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/imagare[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/imagare/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/imagare/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[importer]]
 === importer
@@ -388,9 +387,9 @@
 plugin it can be used to rename a project.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/importer[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/importer/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[its-plugins]]
 === Issue Tracker System Plugins
@@ -404,11 +403,11 @@
 framework for the ITS plugins which is packaged within each ITS plugin.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-base[
-its-base Project] |
+its-base Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-base/+doc/master/src/main/resources/Documentation/about.md[
-its-base Documentation] |
+its-base Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-base/+doc/master/src/main/resources/Documentation/config.md[
-its-base Configuration]
+its-base Configuration,role=external,window=_blank]
 
 [[its-bugzilla]]
 ==== its-bugzilla
@@ -416,9 +415,9 @@
 Plugin to integrate with Bugzilla.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-bugzilla/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[its-jira]]
 ==== its-jira
@@ -426,9 +425,9 @@
 Plugin to integrate with Jira.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-jira[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-jira/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[its-phabricator]]
 ==== its-phabricator
@@ -436,9 +435,9 @@
 Plugin to integrate with Phabricator.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-phabricator[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-phabricator/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[its-rtc]]
 ==== its-rtc
@@ -446,9 +445,9 @@
 Plugin to integrate with IBM Rational Team Concert (RTC).
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-rtc/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[its-storyboard]]
 ==== its-storyboard
@@ -456,9 +455,9 @@
 Plugin to integrate with Storyboard task tracking system.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-storyboard[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/its-storyboard/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[javamelody]]
 === javamelody
@@ -469,11 +468,11 @@
 instrumentation data from Gerrit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/javamelody[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/javamelody/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 https://gerrit.googlesource.com/plugins/javamelody/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[labelui]]
 === labelui
@@ -484,9 +483,9 @@
 screen).
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/labelui[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/labelui/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[menuextender]]
 === menuextender
@@ -495,11 +494,11 @@
 additional menu entries from the WebUI.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/menuextender[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/menuextender/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/menuextender/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[metrics-reporter-elasticsearch]]
 === metrics-reporter-elasticsearch
@@ -507,7 +506,7 @@
 This plugin reports Gerrit metrics to Elasticsearch.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-elasticsearch[
-Project].
+Project,role=external,window=_blank].
 
 [[metrics-reporter-graphite]]
 === metrics-reporter-graphite
@@ -515,7 +514,7 @@
 This plugin reports Gerrit metrics to Graphite.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-graphite[
-Project].
+Project,role=external,window=_blank].
 
 [[metrics-reporter-jmx]]
 === metrics-reporter-jmx
@@ -523,7 +522,7 @@
 This plugin reports Gerrit metrics to JMX.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-jmx[
-Project].
+Project,role=external,window=_blank].
 
 [[metrics-reporter-prometheus]]
 === metrics-reporter-prometheus
@@ -531,7 +530,7 @@
 This plugin exposes Gerrit metrics for consumption by Prometheus.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-prometheus[
-Project].
+Project,role=external,window=_blank].
 
 [[motd]]
 === motd
@@ -543,11 +542,11 @@
 discarded otherwise.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/motd[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/motd/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/motd/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[oauth-authentication-provider]]
 === OAuth authentication provider
@@ -565,16 +564,16 @@
 * Keycloak
 * Office365
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/oauth[Project] |
-link:https://gerrit.googlesource.com/plugins/oauth/+doc/master/src/main/resources/Documentation/config.md[Configuration]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/oauth[Project,role=external,window=_blank] |
+link:https://gerrit.googlesource.com/plugins/oauth/+doc/master/src/main/resources/Documentation/config.md[Configuration,role=external,window=_blank]
 
 [[owners]]
 === owners
 This plugin provides a Prolog predicate `add_owner_approval/3` that
 appends `label('Owner-Approval', need(_))` to a provided list.
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/owners[Project] |
-link:https://gerrit.googlesource.com/plugins/owners/+doc/master/README.md[Documentation]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/owners[Project,role=external,window=_blank] |
+link:https://gerrit.googlesource.com/plugins/owners/+doc/master/README.md[Documentation,role=external,window=_blank]
 
 [[project-download-commands]]
 === project-download-commands
@@ -586,11 +585,11 @@
 inherited download command or remove it by assigning no value to it.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/project-download-commands[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/project-download-commands/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/project-download-commands/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[quota]]
 === quota
@@ -602,21 +601,21 @@
 can use this plugin to define quotas on project namespaces.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/quota[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[rabbitmq]]
 === rabbitmq
 
 A plugin that publishes Gerrit events to a
-link:https://www.rabbitmq.com/[RabbitMQ] exchange.
+link:https://www.rabbitmq.com/[RabbitMQ,role=external,window=_blank] exchange.
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/rabbitmq[Project]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/rabbitmq[Project,role=external,window=_blank]
 link:https://gerrit.googlesource.com/plugins/rabbitmq/+/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[readonly]]
 === readonly
@@ -625,11 +624,11 @@
 blocking HTTP PUT/POST/DELETE requests, and disabling SSH commands.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/readonly[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/readonly/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/readonly/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[ref-protection]]
 === ref-protection
@@ -640,9 +639,9 @@
 `refs/backups/` namespace.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/ref-protection[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/ref-protection/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[reparent]]
 === reparent
@@ -650,11 +649,11 @@
 A plugin that provides project reparenting as a self-service for project owners.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/reparent[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reparent/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reparent/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[review-strategy]]
 === review-strategy
@@ -662,9 +661,9 @@
 This plugin allows users to configure different review strategies.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/review-strategy[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/review-strategy/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[reviewers]]
 === reviewers
@@ -672,11 +671,11 @@
 A plugin that allows adding default reviewers to a change.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reviewers/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reviewers/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[reviewers-by-blame]]
 === reviewers-by-blame
@@ -688,11 +687,11 @@
 change.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reviewers-by-blame/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/reviewers-by-blame/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[groovy-provider]]
 === scripting/groovy-provider
@@ -700,16 +699,16 @@
 This plugin provides a Groovy runtime environment for Gerrit plugins in Groovy.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripting/groovy-provider[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/scripting/groovy-provider/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[saml-authentication-provider]]
 === SAML2 authentication provider
 
 This plugin enables Gerrit to use SAML2 protocol for authentication.
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/saml[Project]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/saml[Project,role=external,window=_blank]
 
 [[scala-provider]]
 === scripting/scala-provider
@@ -717,9 +716,9 @@
 This plugin provides a Scala runtime environment for Gerrit plugins in Scala.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripting/scala-provider[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/scripting/scala-provider/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
+Documentation,role=external,window=_blank]
 
 [[scripts]]
 === scripts
@@ -730,8 +729,8 @@
 Groovy and Scala scripts require the installation of the corresponding
 scripting/*-provider plugin in order to be loaded into Gerrit.
 
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripts[Project]
-link:https://gerrit.googlesource.com/plugins/scripts/+doc/master/README.md[Documentation]
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripts[Project,role=external,window=_blank]
+link:https://gerrit.googlesource.com/plugins/scripts/+doc/master/README.md[Documentation,role=external,window=_blank]
 
 [[server-config]]
 === server-config
@@ -743,7 +742,7 @@
 get.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/server-config[
-Project]
+Project,role=external,window=_blank]
 
 [[serviceuser]]
 === serviceuser
@@ -756,11 +755,11 @@
 WebUI and it cannot push commits or tags.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/serviceuser[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/serviceuser/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/serviceuser/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[uploadvalidator]]
 === uploadvalidator
@@ -772,11 +771,11 @@
 settings are rejected by Gerrit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/uploadvalidator[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/uploadvalidator/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/uploadvalidator/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[verify-status]]
 === verify-status
@@ -786,11 +785,11 @@
 or in a completely separate datastore.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/verify-status[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/verify-status/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/verify-status/+doc/master/src/main/resources/Documentation/database.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[websession-flatfile]]
 === websession-flatfile
@@ -801,11 +800,11 @@
 Gerrit installations having multiple primary Gerrit nodes.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/websession-flatfile[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/websession-flatfile/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/websession-flatfile/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 [[x-docs]]
 === x-docs
@@ -813,11 +812,11 @@
 This plugin serves project documentation as HTML pages.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/x-docs[
-Project] |
+Project,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/x-docs/+doc/master/src/main/resources/Documentation/about.md[
-Documentation] |
+Documentation,role=external,window=_blank] |
 link:https://gerrit.googlesource.com/plugins/x-docs/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
+Configuration,role=external,window=_blank]
 
 
 GERRIT
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 71af331..c298ba1 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -316,10 +316,14 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-- 'mergeContent': Defines whether to automatically merge changes.  Valid values
-are 'true', 'false', or 'INHERIT'.  Default is 'INHERIT'.
+[[content_merge]]
+- 'mergeContent': Defines whether Gerrit will try to
+do a content merge when a path conflict occurs. Valid values are
+'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
+be modified by any project owner through the project console, `Browse`
+> `Repositories` > my/project > `Allow content merges`.
 
-- 'action': defines the link:#submit-type[submit type].  Valid
+- 'action': Defines the link:#submit-type[submit type].  Valid
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
 'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
 
@@ -334,7 +338,97 @@
 commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
 the initial commit on a branch.
 
-Merge strategy
+[[submit-type]]
+==== Submit Type
+
+'submit.action': The method Gerrit uses to submit a change to a project.
+
+The submit type can also be modified by any project owner through the
+project console, `Browse` > `Repositories` > my/project > 'Submit type'.
+In general, a submitting a change only merges the change if all its
+dependencies are also submitted, with exceptions documented below.
+
+The following submit types are supported:
+
+[[submit_type_inherit]]
+* Inherit
++
+This is the default for new projects, unless overridden by a global
+link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
++
+Inherit the submit type from the parent project. In `All-Projects`, this
+is equivalent to link:#merge_if_necessary[Merge If Necessary].
+
+[[fast_forward_only]]
+* Fast Forward Only
++
+With this method Gerrit does not create merge commits on submitting a
+change. Merge commits may still be submitted, but they must be created
+on the client prior to uploading to Gerrit for review.
++
+To submit a change, the change must be a strict superset of the
+destination branch.  That is, the change must already contain the
+tip of the destination branch at submit time.
+
+[[merge_if_necessary]]
+* Merge If Necessary
++
+If the change being submitted is a strict superset of the destination
+branch, then the branch is fast-forwarded to the change.  If not,
+then a merge commit is automatically created.  This is identical
+to the classical `git merge` behavior, or `git merge --ff`.
+
+[[always_merge]]
+* Always Merge
++
+Always produce a merge commit, even if the change is a strict
+superset of the destination branch.  This is identical to the
+behavior of `git merge --no-ff`, and may be useful if the
+project needs to follow submits with `git log --first-parent`.
+
+[[cherry_pick]]
+* Cherry Pick
++
+Always cherry pick the patch set, ignoring the parent lineage
+and instead creating a brand new commit on top of the current
+branch head.
++
+When cherry picking a change, Gerrit automatically appends onto the
+end of the commit message a short summary of the change's approvals,
+and a URL link back to the change on the web.  The committer header
+is also set to the submitter, while the author header retains the
+original patch set author.
++
+Note that Gerrit ignores dependencies between changes when using this
+submit type unless
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled and depending changes share the same topic. So generally
+submitters must remember to submit changes in the right order when using this
+submit type. If all you want is extra information in the commit message,
+consider using the Rebase Always submit strategy.
+
+[[rebase_if_necessary]]
+* Rebase If Necessary
++
+If the change being submitted is a strict superset of the destination
+branch, then the branch is fast-forwarded to the change.  If not,
+then the change is automatically rebased and then the branch is
+fast-forwarded to the change.
++
+When Gerrit tries to do a merge, by default the merge will only
+succeed if there is no path conflict.  A path conflict occurs when
+the same file has also been changed on the other side of the merge.
+
+[[rebase_always]]
+* Rebase Always
++
+Basically, the same as Rebase If Necessary, but it creates a new patchset even
+if fast forward is possible AND like Cherry Pick it ensures footers such as
+Change-Id, Reviewed-On, and others are present in resulting commit that is
+merged.
++
+Thus, Rebase Always can be considered similar to Cherry Pick, but with
+the important distinction that Rebase Always does not ignore dependencies.
 
 
 [[access-section]]
@@ -473,100 +567,6 @@
 You can read more about the +rules.pl+ file and the prolog rules on
 link:prolog-cookbook.html[the Prolog cookbook page].
 
-[[submit-type]]
-=== Submit Type
-
-The method Gerrit uses to submit a change to a project can be
-modified by any project owner through the project console, `Projects` >
-`List` > my/project. In general, a submitted change is only merged if all
-its dependencies are also submitted, with exceptions documented below.
-The following submit types are supported:
-
-[[submit_type_inherit]]
-* Inherit
-+
-This is the default for new projects, unless overridden by a global
-link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
-+
-Inherit the submit type from the parent project. In `All-Projects`, this
-is equivalent to link:#merge_if_necessary[Merge If Necessary].
-
-[[fast_forward_only]]
-* Fast Forward Only
-+
-With this method Gerrit does not create merge commits on submitting a
-change. Merge commits may still be submitted, but they must be created
-on the client prior to uploading to Gerrit for review.
-+
-To submit a change, the change must be a strict superset of the
-destination branch.  That is, the change must already contain the
-tip of the destination branch at submit time.
-
-[[merge_if_necessary]]
-* Merge If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then a merge commit is automatically created.  This is identical
-to the classical `git merge` behavior, or `git merge --ff`.
-
-[[always_merge]]
-* Always Merge
-+
-Always produce a merge commit, even if the change is a strict
-superset of the destination branch.  This is identical to the
-behavior of `git merge --no-ff`, and may be useful if the
-project needs to follow submits with `git log --first-parent`.
-
-[[cherry_pick]]
-* Cherry Pick
-+
-Always cherry pick the patch set, ignoring the parent lineage
-and instead creating a brand new commit on top of the current
-branch head.
-+
-When cherry picking a change, Gerrit automatically appends onto the
-end of the commit message a short summary of the change's approvals,
-and a URL link back to the change on the web.  The committer header
-is also set to the submitter, while the author header retains the
-original patch set author.
-+
-Note that Gerrit ignores dependencies between changes when using this
-submit type unless
-link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-is enabled and depending changes share the same topic. So generally
-submitters must remember to submit changes in the right order when using this
-submit type. If all you want is extra information in the commit message,
-consider using the Rebase Always submit strategy.
-
-[[rebase_if_necessary]]
-* Rebase If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then the change is automatically rebased and then the branch is
-fast-forwarded to the change.
-+
-When Gerrit tries to do a merge, by default the merge will only
-succeed if there is no path conflict.  A path conflict occurs when
-the same file has also been changed on the other side of the merge.
-
-[[rebase_always]]
-* Rebase Always
-+
-Basically, the same as Rebase If Necessary, but it creates a new patchset even
-if fast forward is possible AND like Cherry Pick it ensures footers such as
-Change-Id, Reviewed-On, and others are present in resulting commit that is
-merged.
-+
-Thus, Rebase Always can be considered similar to Cherry Pick, but with
-the important distinction that Rebase Always does not ignore dependencies.
-
-[[content_merge]]
-=== Allow content merges
-If `Allow content merges` is enabled, Gerrit will try
-to do a content merge when a path conflict occurs.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-sso.txt b/Documentation/config-sso.txt
index 6f3a32d..14399a3 100644
--- a/Documentation/config-sso.txt
+++ b/Documentation/config-sso.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Single Sign-On Security
 
 Gerrit supports integration with some types of single sign-on
@@ -17,7 +18,7 @@
 As this is the default setting there is nothing required from the
 site administrator to make use of the OpenID authentication services.
 
-* http://openid.net/[openid.net]
+* http://openid.net/[openid.net,,role=external,window=_blank]
 
 If Jetty is being used, you may need to increase the header
 buffer size parameter, due to very long header lines.
@@ -34,7 +35,7 @@
 `auth.trustedOpenID` list in `gerrit.config`.  Patterns may be
 either a
 link:http://download.oracle.com/javase/6/docs/api/java/util/regex/Pattern.html[standard
-Java regular expression (java.util.regex)] (must start with `^`
+Java regular expression (java.util.regex),,role=external,window=_blank] (must start with `^`
 and end with `$`) or be a simple prefix (any other string).
 
 Out of the box Gerrit is configured to trust two patterns, which
@@ -65,12 +66,12 @@
 subsequent attempts to link that account with the existing account will fail.
 In cases where this happens, the administrator will need to manually merge the
 accounts.  See link:https://gerrit.googlesource.com/homepage/+/md-pages/docs/SqlMergeUserAccounts.md[
-Merging Gerrit User Accounts] on the Gerrit Wiki for details.
+Merging Gerrit User Accounts,,role=external,window=_blank] on the Gerrit Wiki for details.
 
 Linking another identity is also useful for users whose primary OpenID provider
 shuts down. For example Google
 link:https://developers.google.com/+/api/auth-migration[shut down their OpenID
-service on 20th April 2015]. Users who failed to add an alternative identity with
+service on 20th April 2015,,role=external,window=_blank]. Users who failed to add an alternative identity with
 another OpenID provider before that date will end up with their account only having
 a disabled Google identity. After creating a separate account with an alternative
 provider, they will need to ask the administrator to merge the accounts using the
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 4964568..116ac76 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -1,5 +1,17 @@
+:linkattrs:
 = Gerrit Code Review - Building with Bazel
 
+[[summary]]
+== TL;DR
+
+If you have the prerequisites, running
+
+```
+  $ bazel build gerrit
+```
+
+should generate a .war file under `bazel-bin/gerrit.war`.
+
 [[installation]]
 == Prerequisites
 
@@ -8,10 +20,10 @@
 * A Linux or macOS system (Windows is not supported at this time)
 * A JDK for Java 8|9|10|11|...
 * Python 2 or 3
-* link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm)]
+* link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Bower (`sudo npm install -g bower`)
-* link:https://docs.bazel.build/versions/master/install.html[Bazel] directly
-or through link:https://github.com/bazelbuild/bazelisk[Bazelisk]
+* link:https://docs.bazel.build/versions/master/install.html[Bazel,role=external,window=_blank] directly
+or through link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank]
 * Maven
 * zip, unzip
 * gcc
@@ -32,17 +44,17 @@
 
 `java -version`
 
-[[java-12]]
-==== Java 12 support
+[[java-13]]
+==== Java 13 support
 
-Java 12 (and newer) is supported through vanilla java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 12 and newer, specify vanilla java toolchain and
+Java 13 (and newer) is supported through vanilla java toolchain
+link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option,role=external,window=_blank].
+To build Gerrit with Java 13 and newer, specify vanilla java toolchain and
 provide the path to JDK home:
 
 ```
   $ bazel build \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-12> \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-13> \
     --javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
@@ -55,7 +67,7 @@
 
 ```
   $ bazel test \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-12> \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-13> \
     --javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
     --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
@@ -68,7 +80,7 @@
 
 ```
 $ cat << EOF > ~/.bazelrc
-> build --define=ABSOLUTE_JAVABASE=<path-to-java-12>
+> build --define=ABSOLUTE_JAVABASE=<path-to-java-13>
 > build --javabase=@bazel_tools//tools/jdk:absolute_javabase
 > build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
 > build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
@@ -96,7 +108,7 @@
 ```
 
 === Node.js and npm packages
-See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages].
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages,role=external,window=_blank].
 
 [[build]]
 == Building on the Command Line
@@ -212,7 +224,7 @@
 
 === IntelliJ
 
-The Gerrit build works with Bazel's link:https://ij.bazel.build[IntelliJ plugin].
+The Gerrit build works with Bazel's link:https://ij.bazel.build[IntelliJ plugin,role=external,window=_blank].
 Please follow the instructions on <<dev-intellij#,IntelliJ Setup>>.
 
 === Eclipse
@@ -232,7 +244,7 @@
 If an updated classpath is needed, the Eclipse project can be
 refreshed and missing dependency JARs can be downloaded by running
 `project.py` again. For IntelliJ, you need to click the `Sync Project
-with BUILD Files` button of link:https://ij.bazel.build[Bazel plugin].
+with BUILD Files` button of link:https://ij.bazel.build[Bazel plugin,role=external,window=_blank].
 
 [[documentation]]
 === Documentation
@@ -342,17 +354,17 @@
 
 Successfully running the Elasticsearch tests requires Docker, and
 may require setting the local virtual memory on
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[linux] and
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_set_vm_max_map_count_to_at_least_262144[macOS].
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[linux,role=external,window=_blank] and
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_set_vm_max_map_count_to_at_least_262144[macOS,role=external,window=_blank].
 
-On macOS, if using link:https://docs.docker.com/docker-for-mac/[Docker Desktop],
+On macOS, if using link:https://docs.docker.com/docker-for-mac/[Docker Desktop,role=external,window=_blank],
 the effective memory value can be set in the Preferences, under the Advanced tab.
 The default value usually does not suffice and is causing premature container exits.
 That default is currently 2 GB and should be set to at least 5 (GB).
 
 If Docker is not available, the Elasticsearch tests will be skipped.
 Note that Bazel currently does not show
-link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests].
+link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests,role=external,window=_blank].
 
 == Dependencies
 
@@ -465,7 +477,7 @@
 * ~/.gerritcodereview/bazel-cache/cas
 
 Currently none of these caches have a maximum size limit. See
-link:https://github.com/bazelbuild/bazel/issues/5139[this bazel issue] for
+link:https://github.com/bazelbuild/bazel/issues/5139[this bazel issue,role=external,window=_blank] for
 details. Users should watch the cache sizes and clean them manually if
 necessary.
 
@@ -476,7 +488,7 @@
 "binaries". We don't attempt to resolve and download NPM dependencies at build
 time, but instead use pre-built bundles of the NPM binary along with all its
 dependencies. Some packages on
-link:https://docs.npmjs.com/misc/registry[registry.npmjs.org] come with their
+link:https://docs.npmjs.com/misc/registry[registry.npmjs.org,role=external,window=_blank] come with their
 dependencies bundled, but this is the exception rather than the rule. More
 commonly, to add a new binary to this list, you will need to bundle the binary
 yourself.
@@ -504,7 +516,7 @@
 storage bucket if the licenses allow us to do so. As long as all of the listed
 license are allowed by
 link:https://opensource.google.com/docs/thirdparty/licenses/[Google's
-standards]. Any `by_exception_only`, commercial, prohibited, or unlisted
+standards,role=external,window=_blank]. Any `by_exception_only`, commercial, prohibited, or unlisted
 licenses are not allowed; otherwise, it is ok to distribute the source. If in
 doubt, contact a maintainer who is a Googler.
 
@@ -529,7 +541,7 @@
 
 Any project maintainer can upload this file to the
 link:https://console.cloud.google.com/storage/browser/gerrit-maven/npm-packages[storage
-bucket].
+bucket,role=external,window=_blank].
 
 Finally, add the new binary to the build process:
 ----
diff --git a/Documentation/dev-cla.txt b/Documentation/dev-cla.txt
index 267351f..a5a9932 100644
--- a/Documentation/dev-cla.txt
+++ b/Documentation/dev-cla.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Contributor License Agreement
 
 In order to link::dev-community.html#how-to-contribute[contribute] to
@@ -6,17 +7,17 @@
 following:
 
 . Click 'Sign In' at the top right corner of
-  https://gerrit-review.googlesource.com/
+  https://gerrit-review.googlesource.com/[role=external,window=_blank]
 . Sign In with your Google account
 . After signing in, go to the
-  link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements]
+  link:https://gerrit-review.googlesource.com/#/settings/agreements[Agreements,role=external,window=_blank]
   tab on the settings page
 . Click on 'New Contributor Agreement' and follow the instructions
 
 For reference, the actual agreements are linked below:
 
-* link:https://cla.developers.google.com/about/google-individual[Individual Agreement]
-* link:https://cla.developers.google.com/about/google-corporate[Corporate Agreement]
+* link:https://cla.developers.google.com/about/google-individual[Individual Agreement,role=external,window=_blank]
+* link:https://cla.developers.google.com/about/google-corporate[Corporate Agreement,role=external,window=_blank]
 
 GERRIT
 ------
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index a497064..5892253 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -1,19 +1,20 @@
+:linkattrs:
 = Gerrit Community
 
 Gerrit is developed as a
-link:https://gerrit-review.googlesource.com/[self-hosting open source project]
+link:https://gerrit-review.googlesource.com/[self-hosting open source project,role=external,window=_blank]
 and very much welcomes contributions from anyone with a
 link:dev-cla.html[contributor's agreement] on file with the project.
 
 [[project-information]]
 == Project Information
 
-* link:https://www.gerritcodereview.com/[Project Homepage]
-* link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct]
-* link:https://www.gerritcodereview.com/releases-readme.html[Release Versions]
-* link:https://gerrit.googlesource.com/gerrit[Source]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
-* link:https://gerrit-review.googlesource.com/q/status:open+project:gerrit[Change Review]
+* link:https://www.gerritcodereview.com/[Project Homepage,role=external,window=_blank]
+* link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct,role=external,window=_blank]
+* link:https://www.gerritcodereview.com/releases-readme.html[Release Versions,role=external,window=_blank]
+* link:https://gerrit.googlesource.com/gerrit[Source,role=external,window=_blank]
+* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking,role=external,window=_blank]
+* link:https://gerrit-review.googlesource.com/q/status:open+project:gerrit[Change Review,role=external,window=_blank]
 * link:dev-design.html[System Design]
 * Processes
 ** link:dev-processes.html#project-governance[Project Governance / Engineering Steering Committee]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 0bac643..70fbd60 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Contributing
 
 [[cla]]
@@ -33,7 +34,7 @@
 design-driven contribution process instead.
 
 If you are in doubt which process is right for you, consult the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 
 These contribution processes apply to everyone who contributes code to
@@ -83,11 +84,11 @@
 be reviewed before they will get submitted to the code base.  To
 start your contribution, please make a git commit and upload it
 for review to the link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com] Gerrit server.  To help speed up the
+gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To help speed up the
 review of your change, review these link:dev-crafting-changes.html[
 guidelines] before submitting your change.  You can view the pending
 Gerrit contributions and their statuses
-link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here].
+link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
 your change to get reviewed.  Naturally there are fewer
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
new file mode 100644
index 0000000..12726b7
--- /dev/null
+++ b/Documentation/dev-core-plugins.txt
@@ -0,0 +1,95 @@
+:linkattrs:
+= Gerrit Code Review - Core Plugins
+
+[[definition]]
+== What are core plugins?
+
+Core plugins are plugins that are packaged within the Gerrit war file. This
+means during the link:pgm-init.html[Gerrit initialization] they can be easily
+installed without downloading any additional files.
+
+To make working with core plugins easy, they are linked as
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/.gitmodules[Git
+submodules,role=external,window=_blank] in the `gerrit` repository. E.g. this means they can be easily
+link:dev-readme.html#clone[cloned] together with Gerrit.
+
+All core plugins are developed and maintained by the
+link:dev-roles.html#maintainers[Gerrit maintainers] and everyone can
+link:dev-contributing.html[contribute] to them.
+
+Adding a new core plugin feature that is large or complex requires a
+link:dev-design-doc.html[design doc] (also see
+link:dev-contributing.html#design-driven-contribution-process[design-driven
+contribution process]). The link:dev-processes.html#steering-committee[
+engineering steering committee (ESC)] is the authority that approves the design
+docs. The ESC is also in charge of adding and removing core plugins.
+
+Non-Gerrit maintainers cannot have link:access-control.html#category_owner[
+Owner] permissions for core plugins.
+
+[[criteria]]
+=== Criteria for Core Plugins
+
+To be considered as a core plugin, a plugin must fulfill the following
+criteria:
+
+1. License:
++
+The plugin code is available under the
+link:http://www.apache.org/licenses/LICENSE-2.0[Apache License Version 2.0,role=external,window=_blank].
+
+2. Hosting:
++
+The plugin development is hosted on the
+link:https://gerrit-review.googlesource.com[gerrit-review,role=external,window=_blank] Gerrit Server.
+
+3. Scope:
++
+The plugin functionality is Gerrit-related, has a clear scope and does not
+conflict with other core plugins or existing and planned Gerrit core features.
+
+4. Relevance:
++
+The plugin functionality is relevant to a majority of the Gerrit community:
++
+--
+** An out of the box Gerrit installation would seem like it is missing
+   something if the plugin is not installed.
+** It's expected that most sites would use the plugin.
+** Multiple parties (different organizations/companies) already use the plugin
+   and agree that it should be offered as core plugin.
+** If the same or similar functionality is provided by multiple plugins,
+   the plugin is the clear recommended solution by the community.
+--
++
+Whether a plugin is relevant to a majority of the Gerrit community must be
+discussed on a case-by-case basis. In case of doubt, it's up to the
+link:dev-processes.html#steering-committee[engineering steering committee] to
+make a decision.
+
+5. Code Quality:
++
+The plugin code is mature and has a good test coverage. Maintaining the plugin
+code creates only little overhead for the Gerrit maintainers.
+
+6. Documentation:
++
+The plugin functionality is fully documented.
+
+7. Ownership:
++
+Existing plugin owners which are not Gerrit maintainers must agree to give up
+their ownership. If the current plugin owners disagree, forking the plugin is
+possible, but this should happen only in exceptional cases.
+
+[[list]]
+== Which core plugins exist?
+
+See link:config-plugins.html#core-plugins[here].
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index bf4453c..2364fcc 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Crafting Changes
 
 Here are some hints as to what approvers may be looking for
@@ -86,10 +87,10 @@
 The HTTPS access requires proper username and password; this can be obtained
 by clicking the 'Obtain Password' link on the
 link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
-Password tab of the user settings page].
+Password tab of the user settings page,role=external,window=_blank].
 
 Alternately, you may use the
-link:https://pypi.org/project/git-review/[git-review] tool to submit changes
+link:https://pypi.org/project/git-review/[git-review,role=external,window=_blank] tool to submit changes
 to Gerrit. If you do, it will set up the Change-Id hook and `gerrit` remote
 for you. You will still need to do the HTTP access step.
 
@@ -109,12 +110,12 @@
 
 Gerrit generally follows the
 link:https://google.github.io/styleguide/javaguide.html[Google Java Style
-Guide].
+Guide,role=external,window=_blank].
 
 To format Java source code, Gerrit uses the
-link:https://github.com/google/google-java-format[`google-java-format`]
+link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
 tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
-link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`]
+link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`,role=external,window=_blank]
 tool (version 0.29.0).
 These tools automatically apply format according to the style guides; this
 streamlines code review by reducing the need for time-consuming, tedious,
@@ -230,6 +231,13 @@
 
   * Tests for new code will greatly help your change get approved.
 
+[[javadoc]]
+== Javadoc
+
+  * Javadocs for new code (especially public classes and
+    public/protected methods) will greatly help your change get
+    approved.
+
 [[change-size]]
 == Change Size/Number of Files Touched
 
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index 5e3f7a9..ca5ff62 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -72,7 +72,7 @@
 
 To propose a new design, upload a change to the
 link:https://gerrit-review.googlesource.com/admin/repos/homepage[
-homepage] repository that adds a new folder under `pages/design-docs/`
+homepage,role=external,window=_blank] repository that adds a new folder under `pages/design-docs/`
 which contains at least an `index.md` and a `uses-cases.md` file (see
 link:#structure[design doc structure] above).
 
@@ -87,7 +87,7 @@
 
 Only very few maintainers actively watch out for uploaded design docs.
 To raise awareness you may want to send a notification to the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list about your uploaded design doc. But the discussion should
 not take place on the mailing list, comments should be made by reviewing
 the change in Gerrit.
@@ -122,7 +122,7 @@
 
 . Go to the
   link:https://gerrit-review.googlesource.com/settings/#Notifications[
-  notification settings]
+  notification settings,role=external,window=_blank]
 . Add a project watch for the `homepage` repository with the following
   query: `dir:pages/design-docs`
 
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index fd53cac..c94862e 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - System Design
 
 == Objective
@@ -68,10 +69,10 @@
 Since Gerrit 3.x link:note-db.html[NoteDb] replaced the SQL database
 and all metadata is now stored in Git.
 
-* link:http://video.google.com/videoplay?docid=-8502904076440714866[Mondrian Code Review On The Web]
-* link:https://github.com/rietveld-codereview/rietveld[Rietveld - Code Review for Subversion]
-* link:http://eagain.net/gitweb/?p=gitosis.git;a=blob;f=README.rst;hb=HEAD[Gitosis README]
-* link:http://source.android.com/[Android Open Source Project]
+* link:http://video.google.com/videoplay?docid=-8502904076440714866[Mondrian Code Review On The Web,role=external,window=_blank]
+* link:https://github.com/rietveld-codereview/rietveld[Rietveld - Code Review for Subversion,role=external,window=_blank]
+* link:http://eagain.net/gitweb/?p=gitosis.git;a=blob;f=README.rst;hb=HEAD[Gitosis README,role=external,window=_blank]
+* link:http://source.android.com/[Android Open Source Project,role=external,window=_blank]
 
 
 == Overview
@@ -167,8 +168,8 @@
 requires that the OpenID provider selected by a user must be
 online and operating in order to authenticate that user.
 
-* link:http://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html[Git Repository Format]
-* link:http://openid.net/developers/specs/[OpenID Specifications]
+* link:http://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html[Git Repository Format,role=external,window=_blank]
+* link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank]
 
 *1  Although an effort is underway to eliminate the use of the
 database altogether, and to store all the metadata directly in
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 7329a43..5e39687 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - End to end load tests
 
 This document provides a description of a Gerrit load test scenario implemented using the link:http://gatling.io[`Gatling`] framework.
@@ -8,9 +9,9 @@
 == What is Gatling?
 
 Gatling is a load testing tool which provides out of the box support for the HTTP protocol. Documentation on how to write an
-HTTP load test can be found link:https://gatling.io/docs/current/http/http_protocol/[`here`].
+HTTP load test can be found link:https://gatling.io/docs/current/http/http_protocol/[`here`,role=external,window=_blank].
 
-However, in the scenario we are proposing, we are leveraging the link:https://github.com/GerritForge/gatling-git[`Gatling Git extension`]
+However, in the scenario we are proposing, we are leveraging the link:https://github.com/GerritForge/gatling-git[`Gatling Git extension`,role=external,window=_blank]
 to run tests at Git protocol level.
 
 Gatling is written in Scala, but the abstraction provided by the Gatling DSL makes the scenarios implementation easy even without any Scala knowledge.
@@ -32,7 +33,7 @@
 ==== Setup
 
 If you are running SSH commands the private keys of the users used for testing need to go in `/tmp/ssh-keys`.
-The keys need to be generated this way (JSch won't validate them [otherwise](https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch):
+The keys need to be generated this way (JSch won't validate them [otherwise,role=external,window=_blank](https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch):
 
 ----
 ssh-keygen -m PEM -t rsa -C "test@mail.com" -f /tmp/ssh-keys/id_rsa
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 4364492..5f69cd3 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Eclipse Setup
 
 This document is about configuring Gerrit Code Review into an
@@ -8,7 +9,7 @@
 [[setup]]
 == Project Setup
 
-In your Eclipse installation's link:https://wiki.eclipse.org/Eclipse.ini[`eclipse.ini`] file,
+In your Eclipse installation's link:https://wiki.eclipse.org/Eclipse.ini[`eclipse.ini`,role=external,window=_blank] file,
 add the following line in the `vmargs` section:
 
 ----
@@ -60,13 +61,13 @@
 == Code Formatter Settings
 
 To format source code, Gerrit uses the
-link:https://github.com/google/google-java-format[`google-java-format`]
+link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
 tool (version 1.7), which automatically formats code to follow the
 style guide. See link:dev-crafting-changes.html#style[Code Style] for the
 instruction how to set up command line tool that uses this formatter.
 The Eclipse plugin is provided that allows to format with the same
 formatter from within the Eclipse IDE. See
-link:https://github.com/google/google-java-format#eclipse[Eclipse plugin]
+link:https://github.com/google/google-java-format#eclipse[Eclipse plugin,role=external,window=_blank]
 for details how to install it. It's important to use the same plugin version
 as the `google-java-format` script.
 
diff --git a/Documentation/dev-inspector.txt b/Documentation/dev-inspector.txt
index 39736d7..da6e3aa 100644
--- a/Documentation/dev-inspector.txt
+++ b/Documentation/dev-inspector.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Inspector
 
 == NAME
@@ -33,7 +34,7 @@
 
 Gerrit Inspector requires Jython library ('jython.jar') to be installed
 in the '$site_path/lib' directory. Jython, a Python interpreter for
-the Java Virtual Machine, can be obtained from the http://www.jython.org/
+the Java Virtual Machine, can be obtained from the http://www.jython.org/[role=external,window=_blank]
 website. Only 'jython.jar' file is needed, installation of Jython libraries
 is optional. Gerrit Inspector has been tested with Jython 2.5.2 but
 might work an earlier version.
@@ -87,7 +88,7 @@
 
 For more information on using Jython, especially with regards to its limitations
 in interfacing to the Java Virtual Machine, please refer to the
-http://www.jython.org/[Jython documentation].
+http://www.jython.org/[Jython documentation,role=external,window=_blank].
 
 After successful initialization it is possible to examine components of
 Java packages, classes and live instances.
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index 81790db..b67d546 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - IntelliJ IDEA Setup
 
 == Prerequisites
@@ -14,7 +15,7 @@
 === IntelliJ version and Bazel plugin
 
 Before downloading IntelliJ, look at the
-link:https://plugins.jetbrains.com/plugin/8609-bazel/versions[JetBrains plugin repository page of the Bazel plugin]
+link:https://plugins.jetbrains.com/plugin/8609-bazel/versions[JetBrains plugin repository page of the Bazel plugin,role=external,window=_blank]
 to see what version of the IntelliJ IDEA it is actually compatible with.
 
 Also note that the version of the Bazel plugin used in turn may or may not be
@@ -30,7 +31,7 @@
 === Installation of IntelliJ IDEA
 
 Please refer to the
-link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by Jetbrains]
+link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by Jetbrains,role=external,window=_blank]
 to install it on your platform. Make sure to install a version compatible with
 the Bazel plugin as mentioned above.
 
@@ -47,7 +48,7 @@
 . Search for the plugin `Bazel` (by Google).
 +
 TIP: In case the Bazel plugin is not listed, or if it shows an outdated version,
-verify the compatibility between the Bazel plugin and IntelliJ IDEA on link:https://plugins.jetbrains.com/plugin/8609-bazel/versions[the JetBrains plugin page].
+verify the compatibility between the Bazel plugin and IntelliJ IDEA on link:https://plugins.jetbrains.com/plugin/8609-bazel/versions[the JetBrains plugin page,role=external,window=_blank].
 . Install it.
 . Restart IntelliJ IDEA.
 
@@ -117,7 +118,7 @@
 
 . Download
 https://raw.githubusercontent.com/google/styleguide/gh-pages/intellij-java-google-style.xml[
-intellij-java-google-style.xml].
+intellij-java-google-style.xml,role=external,window=_blank].
 . Go to *File -> Settings -> Editor -> Code Style*.
 . Click on the wrench icon with the tooltip _Show Scheme Actions_.
 . Click on *Import Scheme*.
@@ -196,7 +197,7 @@
 use the instructions of <<dev-readme#run_daemon,Running the Daemon>> in
 combination with <<remote-debug,Debugging a remote Gerrit server>>.
 
-(link:https://bugs.chromium.org/p/gerrit/issues/detail?id=11360[Issue 11360])
+(link:https://bugs.chromium.org/p/gerrit/issues/detail?id=11360[Issue 11360,role=external,window=_blank])
 ====
 
 Copy `$(gerrit_source_code)/tools/intellij/gerrit_daemon.xml` to
diff --git a/Documentation/dev-plugins-lifecycle.txt b/Documentation/dev-plugins-lifecycle.txt
index b552472..d5bd791 100644
--- a/Documentation/dev-plugins-lifecycle.txt
+++ b/Documentation/dev-plugins-lifecycle.txt
@@ -1,7 +1,8 @@
+:linkattrs:
 = Plugin Lifecycle
 
 Most of the plugins are hosted on the same instance as the
-link:https://gerrit-review.googlesource.com[Gerrit project itself] to make them
+link:https://gerrit-review.googlesource.com[Gerrit project itself,role=external,window=_blank] to make them
 more discoverable and have more chances to be reviewed by the whole community.
 
 [[hosting_lifecycle]]
@@ -12,7 +13,7 @@
 - Ideation and Discussion:
 +
 The idea of creating a new plugin is posted and discussed on the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list.
 +
 Also see section link#ideation_discussion[Ideation and discussion] below.
 
@@ -27,14 +28,14 @@
 +
 The author proposes to release the plugin under the
 link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 OpenSource
-license] and requests the plugin to be hosted on
-link:https://gerrit-review.googlesource.com[the Gerrit project site]. The
+license,role=external,window=_blank] and requests the plugin to be hosted on
+link:https://gerrit-review.googlesource.com[the Gerrit project site,role=external,window=_blank]. The
 proposal must be   accepted by at least one Gerrit maintainer. In case of
 disagreement between maintainers, the issue can be escalated to the
 link:dev-processes.html#steering-committee[Engineering Steering Committee]. If
 the plugin is accepted, the Gerrit maintainer creates the project under the
 plugins path on link:https://gerrit-review.googlesource.com[the Gerrit project
-site].
+site,role=external,window=_blank].
 +
 Also see section link#plugin_proposal[Plugin Proposal] below.
 
@@ -42,7 +43,7 @@
 +
 To make the consumption of the plugin easy and to notice plugin breakages early
 the plugin author should setup build jobs on
-link:https://gerrit-ci.gerritforge.com[the GerritForge CI] that build the
+link:https://gerrit-ci.gerritforge.com[the GerritForge CI,role=external,window=_blank] that build the
 plugin for each Gerrit version that it supports.
 +
 Also see section link#build[Build] below.
@@ -58,7 +59,7 @@
 - Release:
 +
 The author releases the plugin by creating a Git tag and announcing the plugin
-on the link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+on the link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 +
 Also see section link#plugin_release[Plugin release] below.
@@ -84,7 +85,7 @@
 contribution of ideas and suggestions by the whole community.
 
 The ideator of the plugin starts with an RFC (Request For Comments) post on the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list
 with a description of the main reasons for starting a new plugin.
 
 Example of a post:
@@ -138,9 +139,9 @@
 
 The author decides that the plugin prototype makes sense as a general purpose
 plugin and decides to release the code with the same
-link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 license]
+link:https://www.apache.org/licenses/LICENSE-2.0.html[Apache 2.0 license,role=external,window=_blank]
 as the Gerrit Code Review project and have it hosted on
-link:https://gerrit-review.googlesource.com[the Gerrit project site].
+link:https://gerrit-review.googlesource.com[the Gerrit project site,role=external,window=_blank].
 
 The plugin author formalizes the proposal with a follow-up of the initial RFC
 post and asks for public opinion on it.
@@ -167,7 +168,7 @@
 - The plugin's project request is widely appreciated and formally accepted by
   at least one Gerrit maintainer who creates the repository as child project of
   'Public-Projects' on link:https://gerrit-review.googlesource.com[the Gerrit
-  project site], creates an associated plugin owners group with "Owner"
+  project site,role=external,window=_blank], creates an associated plugin owners group with "Owner"
   permissions for the plugin and adds the plugin's author as member of it.
 - The plugin's project is widely appreciated; however, another existing plugin
   already partially covers the same use-case and thus it would make more sense
@@ -177,15 +178,15 @@
 - The plugin's project is found useful; however, it is too specific to the
   author's use-case and would not make sense outside of it. The plugin remains
   in a public repository, widely accessible and OpenSource, but not hosted on
-  link:https://gerrit-review.googlesource.com[the Gerrit project site].
+  link:https://gerrit-review.googlesource.com[the Gerrit project site,role=external,window=_blank].
 
 [[build]]
 == Build
 
 The plugin's maintainer creates a job on the
-link:https://gerrit-ci.gerritforge.com[GerritForge CI] by creating a new YAML
+link:https://gerrit-ci.gerritforge.com[GerritForge CI,role=external,window=_blank] by creating a new YAML
 definition in the link:https://gerrit.googlesource.com/gerrit-ci-scripts[Gerrit
-CI Scripts] repository.
+CI Scripts,role=external,window=_blank] repository.
 
 Example of a YAML CI job for plugins:
 
@@ -203,7 +204,7 @@
 
 The plugin follows the same lifecycle as Gerrit Code Review and needs to be
 kept up-to-date with the current active branches, according to the
-link:https://www.gerritcodereview.com/#support[current support policy].
+link:https://www.gerritcodereview.com/#support[current support policy,role=external,window=_blank].
 During the development, the plugin's maintainer can reward contributors
 requesting to be more involved and making them maintainers of his plugin,
 adding them to the list of the project owners.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 11e0666..9714e18 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Plugin Development
 
 The Gerrit server functionality can be extended by installing plugins.
@@ -36,7 +37,7 @@
 
 To get started with the development of a plugin, take a look at
 the samples in the
-link:https://gerrit.googlesource.com/plugins/examples[examples plugin project].
+link:https://gerrit.googlesource.com/plugins/examples[examples plugin project,role=external,window=_blank].
 
 This is a project that demonstrates the various features of the
 plugin API. It can be taken as an example to develop an own plugin.
@@ -367,7 +368,7 @@
 Plugins are loaded from a single JAR file. If a plugin needs
 additional libraries, it must include those dependencies within
 its own JAR. Plugins built using Maven may be able to use the
-link:http://maven.apache.org/plugins/maven-shade-plugin/[shade plugin]
+link:http://maven.apache.org/plugins/maven-shade-plugin/[shade plugin,role=external,window=_blank]
 to package additional dependencies. Relocating (or renaming) classes
 should not be necessary due to the ClassLoader isolation.
 
@@ -2189,7 +2190,7 @@
 
 Gerrit provides an extension point that enables development of
 link:https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md[
-LFS (Large File Storage)] storage plugins. Gerrit core exposes the default LFS
+LFS (Large File Storage),role=external,window=_blank] storage plugins. Gerrit core exposes the default LFS
 protocol endpoint `<project-name>/info/lfs/objects/batch` and forwards the requests
 to the configured link:config-gerrit.html#lfs[lfs.plugin] plugin which implements
 the LFS protocol. By exposing the default LFS endpoint, the git-lfs client can be
@@ -2247,16 +2248,16 @@
 To send Gerrit's metrics data to an external reporting backend, a plugin can
 get a `MetricRegistry` injected and register an instance of a class that
 implements the `Reporter` interface from link:http://metrics.dropwizard.io/[
-DropWizard Metrics].
+DropWizard Metrics,role=external,window=_blank].
 
 Metric reporting plugin implementations are provided for
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX],
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-elasticsearch/[Elastic Search],
-and link:https://gerrit.googlesource.com/plugins/metrics-reporter-graphite/[Graphite].
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank],
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-elasticsearch/[Elastic Search,role=external,window=_blank],
+and link:https://gerrit.googlesource.com/plugins/metrics-reporter-graphite/[Graphite,role=external,window=_blank].
 
 There is also a working example of reporting metrics to the console in the
 link:https://gerrit.googlesource.com/plugins/cookbook-plugin/+/master/src/main/java/com/googlesource/gerrit/plugins/cookbook/ConsoleMetricReporter.java[
-cookbook plugin].
+cookbook plugin,role=external,window=_blank].
 
 === Providing own metrics
 
@@ -2293,7 +2294,7 @@
 
 See the replication metrics in the
 link:https://gerrit.googlesource.com/plugins/replication/+/master/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationMetrics.java[
-replication plugin] for an example of usage.
+replication plugin,role=external,window=_blank] for an example of usage.
 
 [[account-patch-review-store]]
 == AccountPatchReviewStore
@@ -2339,7 +2340,7 @@
 attribute.
 
 Documentation may be written in the Markdown flavor
-link:https://github.com/vsch/flexmark-java[flexmark-java]
+link:https://github.com/vsch/flexmark-java[flexmark-java,role=external,window=_blank]
 if the file name ends with `.md`. Gerrit will automatically convert
 Markdown to HTML if accessed with extension `.html`.
 
@@ -2753,6 +2754,9 @@
 this interface can be used to retry the request instead of failing it
 immediately.
 
+It also allows implementors to group exceptions that have the same
+cause into one metric bucket.
+
 [[mail-soy-template-provider]]
 == MailSoyTemplateProvider
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index f4e77a8..cc6481c 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Development Processes
 
 [[project-governance]]
@@ -7,7 +8,8 @@
 The Gerrit project has an engineering steering committee (ESC) that is
 in charge of:
 
-* Gerrit core (the `gerrit` project) and the core plugins
+* Gerrit core (the `gerrit` project) and the link:dev-core-plugins.html[core
+  plugins]
 * defining the project vision and the project scope
 * maintaining a roadmap, a release plan and a prioritized backlog
 * ensuring timely design reviews
@@ -29,7 +31,7 @@
   period of 1 year (see link:#steering-committee-election[below])
 
 Refer to the project homepage for the link:https://www.gerritcodereview.com/members.html#engineering-steering-committee[
-list of current committee members].
+list of current committee members,role=external,window=_blank].
 
 The steering committee should act in the interest of the Gerrit project
 and the whole Gerrit community.
@@ -83,7 +85,7 @@
 [[versioning]]
 == Semantic versioning
 
-Gerrit follows a light link:https://semver.org/[semantic versioning scheme] MAJOR.MINOR[.PATCH[.HOTFIX]]
+Gerrit follows a light link:https://semver.org/[semantic versioning scheme,role=external,window=_blank] MAJOR.MINOR[.PATCH[.HOTFIX]]
 format:
 
   * MAJOR is incremented when there are substantial incompatible changes and/or
@@ -181,7 +183,7 @@
 
 To report a security vulnerability file a
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Security+Issue[
-security issue] in the Gerrit issue tracker. The visibility of issues that are
+security issue,role=external,window=_blank] in the Gerrit issue tracker. The visibility of issues that are
 created with the `Security Issue` template is automatically restricted to
 Gerrit maintainers and a few long-term contributors. This means as a reporter
 you may not be able to see the issue once it is created. Security issues are
@@ -221,7 +223,7 @@
 address the security vulnerability immediately (either by upgrading to a fixed
 release or applying the mitigation). The information about the security
 vulnerability is disclosed via the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list.
 
 [[handle-security-issue]]
 === Handling of the Security Vulnerability
@@ -252,7 +254,7 @@
 +
 Instead security fixes should be implemented and reviewed in the non-public
 link:https://gerrit-review.googlesource.com/admin/repos/gerrit-security-fixes[
-gerrit-security-fixes] repository which is only accessible by Gerrit
+gerrit-security-fixes,role=external,window=_blank] repository which is only accessible by Gerrit
 maintainers and Gerrit community members that work on security fixes.
 +
 The change that fixes the security vulnerability should contain an integration
@@ -283,7 +285,7 @@
 Once all releases are ready and tested and the announcement is prepared, the
 releases should be all published at the same time. Immediately after that, the
 announcement should be sent out to the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss] mailing list.
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list.
 +
 This ends the embargo and any issue that discusses the security vulnerability
 should be made public.
@@ -292,7 +294,12 @@
 +
 The ESC should discuss if there are any learnings from the security
 vulnerability and define action items to follow up in the
-link:https://bugs.chromium.org/p/gerrit[issue tracker].
+link:https://bugs.chromium.org/p/gerrit[issue tracker,role=external,window=_blank].
+
+[[core-plugins]]
+== Core Plugins
+
+See link:dev-core-plugins.html[here].
 
 [[upgrading-libraries]]
 == Upgrading Libraries
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 02b1891..5f72f37 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -1,10 +1,12 @@
+:linkattrs:
 = Gerrit Code Review: Developer Setup
 
-To build a developer instance, you'll need link:https://bazel.build/[Bazel] to
+To build a developer instance, you'll need link:https://bazel.build/[Bazel,role=external,window=_blank] to
 compile the code.
 
 == Git Setup
 
+[[clone]]
 === Getting the Source
 
 Create a new client workspace:
@@ -30,7 +32,7 @@
 
 CAUTION: If you store Eclipse or IntelliJ project files in the Gerrit source
 directories, do *_not_* run `git clean -fdx`. Doing so may remove untracked files and damage your project. For more information, see
-link:https://git-scm.com/docs/git-clean[git-clean].
+link:https://git-scm.com/docs/git-clean[git-clean,role=external,window=_blank].
 
 Run the following:
 
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index 98a3df5..a4ccccf 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Deploy Gerrit Artifacts
 
 [[deploy-configuration-setting-maven-central]]
@@ -11,7 +12,7 @@
 be done:
 
 * Create an account on
-link:https://issues.sonatype.org/secure/Signup!default.jspa[Sonatype's Jira].
+link:https://issues.sonatype.org/secure/Signup!default.jspa[Sonatype's Jira,role=external,window=_blank].
 +
 Sonatype is the company that runs Maven Central and you need a Sonatype
 account to be able to upload artifacts to Maven Central.
@@ -30,7 +31,7 @@
 repository on Maven Central:
 +
 Ask for this permission by adding a comment on the
-link:https://issues.sonatype.org/browse/OSSRH-7392[OSSRH-7392] Jira
+link:https://issues.sonatype.org/browse/OSSRH-7392[OSSRH-7392,role=external,window=_blank] Jira
 ticket at Sonatype.
 +
 The request needs to be approved by someone who already has this
@@ -43,7 +44,7 @@
 +
 Generate and publish a PGP key as described in
 link:http://central.sonatype.org/pages/working-with-pgp-signatures.html[
-Working with PGP Signatures]. In addition to the keyserver mentioned
+Working with PGP Signatures,role=external,window=_blank]. In addition to the keyserver mentioned
 there it is recommended to also publish the key to the
 link:https://keyserver.ubuntu.com/[Ubuntu key server].
 +
@@ -51,7 +52,7 @@
 while until it is visible to the Sonatype server.
 +
 Add an entry for the public key in the
-link:https://gerrit.googlesource.com/homepage/+/md-pages/releases/public-keys.md[key list]
+link:https://gerrit.googlesource.com/homepage/+/md-pages/releases/public-keys.md[key list,role=external,window=_blank]
 on the homepage.
 +
 The PGP passphrase can be put in `~/.m2/settings.xml`:
@@ -80,7 +81,7 @@
 
 Gerrit Subproject Artifacts are stored on
 link:https://developers.google.com/storage/[Google Cloud Storage].
-Via the link:https://console.developers.google.com/project/164060093628[Developers Console] the
+Via the link:https://console.developers.google.com/project/164060093628[Developers Console,role=external,window=_blank] the
 Gerrit maintainers have access to the `Gerrit Code Review` project.
 This projects host several buckets for storing Gerrit artifacts:
 
@@ -96,7 +97,7 @@
 To upload artifacts to a bucket the user must authenticate with a
 username and password. The username and password need to be retrieved
 from the link:https://console.cloud.google.com/storage/settings?project=api-project-164060093628[
-Storage Setting in the Google Cloud Platform Console]:
+Storage Setting in the Google Cloud Platform Console,role=external,window=_blank]:
 
 Select the `Interoperability` tab, and if no keys are listed under
 `Interoperable storage access keys`, select 'Create a new key'.
diff --git a/Documentation/dev-release-jgit.txt b/Documentation/dev-release-jgit.txt
index 1a8b501..7fbbb95 100644
--- a/Documentation/dev-release-jgit.txt
+++ b/Documentation/dev-release-jgit.txt
@@ -1,8 +1,9 @@
+:linkattrs:
 = Making a Snapshot Release of JGit
 
 This step is only necessary if we need to create an unofficial JGit
 snapshot release and publish it to the
-link:https://developers.google.com/storage/[Google Cloud Storage].
+link:https://developers.google.com/storage/[Google Cloud Storage,role=external,window=_blank].
 
 [[prepare-environment]]
 == Prepare the Maven Environment
diff --git a/Documentation/dev-release-subproject.txt b/Documentation/dev-release-subproject.txt
index c9369b9..fad1681 100644
--- a/Documentation/dev-release-subproject.txt
+++ b/Documentation/dev-release-subproject.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Making a Release of a Gerrit Subproject
 
 [[make-snapshot]]
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 9e1744c..eaf9905 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Making a Gerrit Release
 
 [NOTE]
@@ -158,14 +159,14 @@
 ** SNAPSHOT versions are directly uploaded into the Sonatype snapshots
 repository and no further action is needed:
 +
-https://oss.sonatype.org/content/repositories/snapshots/com/google/gerrit/
+https://oss.sonatype.org/content/repositories/snapshots/com/google/gerrit/[role=external,window=_blank]
 
 ** Release versions are uploaded into a staging repository in the
 link:https://oss.sonatype.org/[Sonatype Nexus Server].
 
 * Verify the staging repository
 
-** Go to the link:https://oss.sonatype.org/[Sonatype Nexus Server] and
+** Go to the link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank] and
 sign in with your Sonatype credentials.
 
 ** Click on 'Build Promotion' in the left navigation bar under
@@ -188,7 +189,7 @@
 ** Test closed staging repository
 +
 Once a repository is closed you can find the URL to it in the `Summary`
-section, e.g. https://oss.sonatype.org/content/repositories/comgooglegerrit-1029
+section, e.g. https://oss.sonatype.org/content/repositories/comgooglegerrit-1029[role=external,window=_blank]
 +
 Use this URL for further testing of the artifacts in this repository,
 e.g. to try building a plugin against the plugin API in this repository
@@ -207,7 +208,7 @@
 +
 How to release a staging repository is described in the
 link:https://docs.sonatype.org/display/Repository/Sonatype+OSS+Maven+Repository+Usage+Guide#SonatypeOSSMavenRepositoryUsageGuide-8.a.2.ReleasingaStagingRepository[
-Sonatype OSS Maven Repository Usage Guide].
+Sonatype OSS Maven Repository Usage Guide,role=external,window=_blank].
 +
 [WARNING]
 Releasing artifacts to Maven Central cannot be undone!
@@ -217,17 +218,17 @@
 click on `Release`.
 
 ** The released artifacts are available in
-https://oss.sonatype.org/content/repositories/releases/com/google/gerrit/
+https://oss.sonatype.org/content/repositories/releases/com/google/gerrit/[role=external,window=_blank]
 
 ** It may take up to 2 hours until the artifacts appear on Maven
 Central:
 +
-http://central.maven.org/maven2/com/google/gerrit/
+http://central.maven.org/maven2/com/google/gerrit/[role=external,window=_blank]
 
 * [optional]: View download statistics
 
 ** Sign in to the
-link:https://oss.sonatype.org/[Sonatype Nexus Server].
+link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank].
 
 ** Click on 'Views/Repositories' in the left navigation bar under
 'Central Statistics'.
@@ -239,7 +240,7 @@
 ==== Publish the Gerrit WAR to the Google Cloud Storage
 
 * go to the link:https://console.cloud.google.com/storage/browser/gerrit-releases/?project=api-project-164060093628[
-gerrit-releases bucket in the Google cloud storage console]
+gerrit-releases bucket in the Google cloud storage console,role=external,window=_blank]
 * make sure you are signed in with your Gmail account
 * manually upload the Gerrit WAR file by using the `Upload` button
 
@@ -248,7 +249,7 @@
 
 * Create the stable branch `stable-$version` in the `gerrit` project via the
 link:https://gerrit-review.googlesource.com/admin/repos/gerrit,branches[
-Gerrit Web UI] or by push.
+Gerrit Web UI,role=external,window=_blank] or by push.
 
 * Push the commits done on `stable-$version` to `refs/for/stable-$version` and
 get them merged.
@@ -282,7 +283,7 @@
 * Upload the files manually via web browser to the appropriate folder
 in the
 link:https://console.cloud.google.com/storage/browser/gerrit-documentation/?project=api-project-164060093628[
-gerrit-documentation] storage bucket.
+gerrit-documentation,role=external,window=_blank] storage bucket.
 
 [[finalize-release-notes]]
 === Finalize the Release Notes
@@ -298,7 +299,7 @@
 ==== Update homepage links
 
 Upload a change on the link:https://gerrit-review.googlesource.com/admin/repos/homepage[
-homepage project] to change the version numbers to the new version.
+homepage project,role=external,window=_blank] to change the version numbers to the new version.
 
 [[update-issues]]
 ==== Update the Issues
@@ -364,7 +365,7 @@
 
 Bazlets is used by gerrit plugins to simplify build process. To allow the
 new released version to be used by gerrit plugins,
-link:https://gerrit.googlesource.com/bazlets/+/master/gerrit_api.bzl#8[gerrit_api.bzl]
+link:https://gerrit.googlesource.com/bazlets/+/master/gerrit_api.bzl#8[gerrit_api.bzl,role=external,window=_blank]
 must reference the new version. Upload a change to bazlets repository with
 api version upgrade.
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index f457667..c16037e 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Supporting Roles
 
 As an open source project Gerrit has a large community of people
@@ -14,19 +15,19 @@
 There are many possibilities to support the project, e.g.:
 
 * get involved in discussions on the
-  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list (post your questions, provide feedback, share your
   experiences, help other users)
 * attend community events like user summits (see
   link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
-  community calendar])
-* report link:https://bugs.chromium.org/p/gerrit/issues/list[issues]
+  community calendar,role=external,window=_blank])
+* report link:https://bugs.chromium.org/p/gerrit/issues/list[issues,role=external,window=_blank]
   and help to clarify existing issues
 * provide feedback on
   link:https://www.gerritcodereview.com/releases-readme.html[new
-  releases and release candidates]
+  releases and release candidates,role=external,window=_blank]
 * review
-  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  link:https://gerrit-review.googlesource.com/q/status:open[changes,role=external,window=_blank]
   and help to verify that they work as advertised, comment if you like
   or dislike a feature
 * serve as contact person for a proprietary Gerrit installation and
@@ -35,7 +36,7 @@
 Supporters can:
 
 * post on the
-  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list (Please note that the `repo-discuss` mailing list is
   managed to prevent spam posts. This means posts from new participants
   must be approved manually before they appear on the mailing list.
@@ -43,7 +44,7 @@
   participate in mailing list discussions frequently are approved
   automatically)
 * comment on
-  link:https://gerrit-review.googlesource.com/q/status:open[changes]
+  link:https://gerrit-review.googlesource.com/q/status:open[changes,role=external,window=_blank]
   and vote from `-1` to `+1` on the `Code-Review` label (these votes
   are important to understand the interest in a change and to address
   concerns early, however link:#maintainer[maintainers] can
@@ -53,7 +54,7 @@
   permissions to vote on the `Verified` label are granted by request,
   see below)
 * file issues in the link:https://bugs.chromium.org/p/gerrit/issues/list[
-  issue tracker] and comment on existing issues
+  issue tracker,role=external,window=_blank] and comment on existing issues
 * support the
   link:dev-processes.html#design-driven-contribution-process[
   design-driven contribution process] by reviewing incoming
@@ -62,7 +63,7 @@
 
 Supporters who want to engage further can get additional privileges
 on request (ask for it on the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list):
 
 * become member of the `gerrit-verifiers` group, which allows to:
@@ -71,10 +72,10 @@
 ** edit topics on all open changes
 ** abandon changes
 * approve posts to the
-  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+  link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list
 * administrate issues in the
-  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker,role=external,window=_blank]
 
 Supporters can become link:#contributor[contributors] by signing a
 contributor license agreement and contributing code to the Gerrit
@@ -87,7 +88,7 @@
 agreement] and who has link:dev-contributing.html[contributed] at least
 one change to any project on
 link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com] is a contributor.
+gerrit-review.googlesource.com,role=external,window=_blank] is a contributor.
 
 Contributions can be:
 
@@ -123,10 +124,10 @@
 
 Contributors may also be invited to join the Gerrit hackathons which
 happen regularly (e.g. twice a year). Hackathons are announced on the
-link:https://groups.google.com/d/forum/repo-discuss[repo-discuss]
+link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list (also see
 link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
-community calendar]).
+community calendar,role=external,window=_blank]).
 
 Outstanding contributors that are actively engaged in the community, in
 activities outlined above, may be nominated as link:#maintainer[
@@ -138,7 +139,7 @@
 Maintainers are the gatekeepers of the project and are in charge of
 approving and submitting changes. Refer to the project homepage for
 the link:https://www.gerritcodereview.com/members.html#maintainers[
-list of current maintainers].
+list of current maintainers,role=external,window=_blank].
 
 Maintainers should only approve changes that:
 
@@ -185,20 +186,20 @@
   link:dev-processes.html#project-governance[Project Governance]
 * nominate new maintainers and vote on nominations (see below)
 * administrate the link:https://groups.google.com/d/forum/repo-discuss[
-  mailing list], the
-  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker]
-  and the link:https://www.gerritcodereview.com/[homepage]
+  mailing list,role=external,window=_blank], the
+  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker,role=external,window=_blank]
+  and the link:https://www.gerritcodereview.com/[homepage,role=external,window=_blank]
 * gain permissions to do Gerrit releases and publish release artifacts
 * create new projects and groups on
   link:https://gerrit-review.googlesource.com/[
-  gerrit-review.googlesource.com]
+  gerrit-review.googlesource.com,role=external,window=_blank]
 * administrate the Gerrit projects on
   link:https://gerrit-review.googlesource.com/[
-  gerrit-review.googlesource.com] (e.g. edit ACLs, update project
+  gerrit-review.googlesource.com,role=external,window=_blank] (e.g. edit ACLs, update project
   configuration)
 * create events in the
   link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
-  community calendar]
+  community calendar,role=external,window=_blank]
 * discuss with other maintainers on the private maintainers mailing
   list and Slack channel
 
@@ -243,7 +244,7 @@
 Members of the steering committee are expected to act in the interest
 of the Gerrit project and the whole Gerrit community. Refer to the project
 homepage for the link:https://www.gerritcodereview.com/members.html#engineering-steering-committee[
-list of current committee members].
+list of current committee members,role=external,window=_blank].
 
 For those that are familiar with scrum, the steering committee member
 role is similar to the role of an agile product owner.
@@ -254,7 +255,7 @@
 requests in a timely manner.
 
 Community members may submit new items under the
-link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:ESC[ESC component]
+link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:ESC[ESC component,role=external,window=_blank]
 in the issue tracker, or add that component to existing items, to raise them to
 the attention of ESC members.
 
@@ -301,7 +302,7 @@
 Community managers should act as stakeholders for the Gerrit community
 and focus on the health of the community. Refer to the project homepage
 for the link:https://www.gerritcodereview.com/members.html#community-managers[
-list of current community managers].
+list of current community managers,role=external,window=_blank].
 
 Tasks:
 
@@ -315,7 +316,7 @@
 * serve as contact person for community issues
 
 Community members may submit new items under the
-link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:Community[Community component]
+link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:Community[Community component,role=external,window=_blank]
 backlog, for community managers to refine. Only public topics should be
 issued through that backlog.
 
diff --git a/Documentation/dev-starter-projects.txt b/Documentation/dev-starter-projects.txt
index ae40ea6..eef4806 100644
--- a/Documentation/dev-starter-projects.txt
+++ b/Documentation/dev-starter-projects.txt
@@ -1,10 +1,11 @@
+:linkattrs:
 = Gerrit Code Review - Starter Projects
 
 We have created a
-link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
+link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject,role=external,window=_blank]
 category in the issue tracker and try to assign easy hack projects to it. If in
 doubt, do not hesitate to ask on the developer
-link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list,role=external,window=_blank].
 
 GERRIT
 ------
diff --git a/Documentation/error-change-closed.txt b/Documentation/error-change-closed.txt
index a239ef1..ae90d6d 100644
--- a/Documentation/error-change-closed.txt
+++ b/Documentation/error-change-closed.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = change ... closed
 
 With this error message Gerrit rejects to push a commit or submit a
@@ -15,7 +16,7 @@
 new change. To do this you have to remove the Change-Id from the
 commit message as explained link:error-push-fails-due-to-commit-message.html[here] and ideally generate a new Change-Id
 using the link:cmd-hook-commit-msg.html[commit hook] or EGit. Before pushing again it is also
-recommended to do a link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase] to base your commit on the submitted
+recommended to do a link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase,role=external,window=_blank] to base your commit on the submitted
 change. Pushing again should now create a new change in Gerrit.
 
 If the change for which you wanted to upload a new patch set was
diff --git a/Documentation/error-changeid-above-footer.txt b/Documentation/error-changeid-above-footer.txt
index abc0186..65d6620 100644
--- a/Documentation/error-changeid-above-footer.txt
+++ b/Documentation/error-changeid-above-footer.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = commit xxxxxxx: Change-Id must be in message footer
 
 With this error message, Gerrit rejects a push of a commit to a project
@@ -8,7 +9,7 @@
 of a commit message. For details, see link:user-changeid.html[Change-Id Lines].
 
 You can see the commit messages for existing commits in the history
-by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
+by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log,role=external,window=_blank].
 
 
 == Change-Id is contained in the commit message but not in the last paragraph
diff --git a/Documentation/error-contains-banned-commit.txt b/Documentation/error-contains-banned-commit.txt
index 13a0eaa..1ed4992 100644
--- a/Documentation/error-contains-banned-commit.txt
+++ b/Documentation/error-contains-banned-commit.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = contains banned commit ...
 
 With this error message Gerrit rejects to push a commit that is
@@ -11,7 +12,7 @@
 error message "contains banned commit ...".
 
 If you have commits that you want to push that are based on a banned
-commit you may want to link:http://www.kernel.org/pub/software/scm/git/docs/git-cherry-pick.html[cherry-pick] them onto a clean base and push
+commit you may want to link:http://www.kernel.org/pub/software/scm/git/docs/git-cherry-pick.html[cherry-pick,role=external,window=_blank] them onto a clean base and push
 them again.
 
 
diff --git a/Documentation/error-has-duplicates.txt b/Documentation/error-has-duplicates.txt
index a520f5d..9f9c8a8 100644
--- a/Documentation/error-has-duplicates.txt
+++ b/Documentation/error-has-duplicates.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = ... has duplicates
 
 With this error message Gerrit rejects to push a commit if its commit
@@ -10,7 +11,7 @@
 
 Since this error should never occur in practice, you should inform
 your Gerrit administrator if you hit this problem and/or
-link:https://bugs.chromium.org/p/gerrit/issues/list[open a Gerrit issue].
+link:https://bugs.chromium.org/p/gerrit/issues/list[open a Gerrit issue,role=external,window=_blank].
 
 In any case to not be blocked with your work, you can simply create a
 new Change-Id for your commit and then push it as new change to
diff --git a/Documentation/error-invalid-author.txt b/Documentation/error-invalid-author.txt
index 5808d4f..d842cb7 100644
--- a/Documentation/error-invalid-author.txt
+++ b/Documentation/error-invalid-author.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = invalid author
 
 For every pushed commit Gerrit verifies that the e-mail address of
@@ -121,7 +122,7 @@
 ----
 
 For further details about git rebase please check the
-link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation].
+link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation,role=external,window=_blank].
 
 
 == Missing privileges to push commits of other users
diff --git a/Documentation/error-invalid-changeid-line.txt b/Documentation/error-invalid-changeid-line.txt
index 9d3d2fc..a0f4b7b 100644
--- a/Documentation/error-invalid-changeid-line.txt
+++ b/Documentation/error-invalid-changeid-line.txt
@@ -1,10 +1,11 @@
+:linkattrs:
 = invalid Change-Id line format in commit message footer
 
 With this error message Gerrit rejects to push a commit if its commit
 message footer contains an invalid Change-Id line.
 
 You can see the commit messages for existing commits in the history
-by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
+by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log,role=external,window=_blank].
 
 If it was the intention to rework a change and to push a new patch
 set, find the change in the Gerrit Web UI, copy its Change-Id line and
diff --git a/Documentation/error-invalid-committer.txt b/Documentation/error-invalid-committer.txt
index a669010..c06eba2 100644
--- a/Documentation/error-invalid-committer.txt
+++ b/Documentation/error-invalid-committer.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = invalid committer
 
 For every pushed commit Gerrit verifies that the e-mail address of
@@ -86,7 +87,7 @@
 commits and then confirming all the commit messages). Just picking
 all the changes will not work as in this case the committer is not
 rewritten. For further details about git rebase please check the
-link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation].
+link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation,role=external,window=_blank].
 
 
 == Missing privileges to push commits that were committed by other users
diff --git a/Documentation/error-missing-changeid.txt b/Documentation/error-missing-changeid.txt
index 27bfea5..3494c3f 100644
--- a/Documentation/error-missing-changeid.txt
+++ b/Documentation/error-missing-changeid.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = commit xxxxxxx: missing Change-Id in message footer
 
 With this error message Gerrit rejects to push a commit to a project
@@ -6,7 +7,7 @@
 a Change-Id.
 
 You can see the commit messages for existing commits in the history
-by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
+by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log,role=external,window=_blank].
 
 To avoid this error you should use the link:cmd-hook-commit-msg.html[commit hook] or EGit to
 automatically create and insert a unique Change-Id into the commit
diff --git a/Documentation/error-missing-subject.txt b/Documentation/error-missing-subject.txt
index 6ef37a4..af628fa 100644
--- a/Documentation/error-missing-subject.txt
+++ b/Documentation/error-missing-subject.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = commit xxxxxxx: missing subject; Change-Id must be in message footer
 
 With this error message Gerrit rejects to push a commit to a project
@@ -9,7 +10,7 @@
 message.
 
 You can see the commit messages for existing commits in the history
-by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
+by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log,role=external,window=_blank].
 
 == Change-Id is the only line in the commit message
 
diff --git a/Documentation/error-multiple-changeid-lines.txt b/Documentation/error-multiple-changeid-lines.txt
index 31567f4..f19ebddd 100644
--- a/Documentation/error-multiple-changeid-lines.txt
+++ b/Documentation/error-multiple-changeid-lines.txt
@@ -1,10 +1,11 @@
+:linkattrs:
 = commit xxxxxxx: multiple Change-Id lines in message footer
 
 With this error message Gerrit rejects to push a commit if the commit
 message footer of the pushed commit contains several Change-Id lines.
 
 You can see the commit messages for existing commits in the history
-by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log].
+by doing a link:http://www.kernel.org/pub/software/scm/git/docs/git-log.html[git log,role=external,window=_blank].
 
 If it was the intention to rework a change and to push a new patch
 set, find the change in the Gerrit Web UI, copy its Change-Id line and
diff --git a/Documentation/error-no-new-changes.txt b/Documentation/error-no-new-changes.txt
index 17422ad..10575fa 100644
--- a/Documentation/error-no-new-changes.txt
+++ b/Documentation/error-no-new-changes.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = no new changes
 
 With this error message Gerrit rejects to push a commit if the pushed
@@ -38,7 +39,7 @@
   in the Gerrit Web UI will not find any change)
 
 If you need to re-push a commit you may rewrite this commit by
-link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[amending] it or doing an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase]. By rewriting the
+link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[amending,role=external,window=_blank] it or doing an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase,role=external,window=_blank]. By rewriting the
 commit you actually create a new commit (with a new commit ID in
 project scope) which can then be pushed to Gerrit.
 
diff --git a/Documentation/error-non-fast-forward.txt b/Documentation/error-non-fast-forward.txt
index 923132e..1e9a88f3 100644
--- a/Documentation/error-non-fast-forward.txt
+++ b/Documentation/error-non-fast-forward.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = non-fast forward
 
 With this error message Gerrit rejects a push if the remote branch can't
@@ -28,8 +29,8 @@
 bypassing code review, your push will be rejected with the error
 message 'non-fast forward'. To solve the problem you have to either
 
-. link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[rebase] your commit on the new tip of the remote branch or
-. link:http://www.kernel.org/pub/software/scm/git/docs/git-merge.html[merge] your commit with the new tip of the remote branch.
+. link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[rebase,role=external,window=_blank] your commit on the new tip of the remote branch or
+. link:http://www.kernel.org/pub/software/scm/git/docs/git-merge.html[merge,role=external,window=_blank] your commit with the new tip of the remote branch.
 
 Afterwards the push should be successful.
 
@@ -46,7 +47,7 @@
 Although it is considered bad practice, it is possible to allow
 non-fast forward updates with Git. For this the remote Git repository
 has to be configured to not deny non-fast forward updates (set the
-link:http://www.kernel.org/pub/software/scm/git/docs/git-config.html[Git configuration] parameter 'receive.denyNonFastForwards' to
+link:http://www.kernel.org/pub/software/scm/git/docs/git-config.html[Git configuration,role=external,window=_blank] parameter 'receive.denyNonFastForwards' to
 'false'). Then it is possible to push a non-fast forward update by
 using the '--force' option.
 
diff --git a/Documentation/error-not-allowed-to-upload-merges.txt b/Documentation/error-not-allowed-to-upload-merges.txt
index d025bd0..5ba4c69 100644
--- a/Documentation/error-not-allowed-to-upload-merges.txt
+++ b/Documentation/error-not-allowed-to-upload-merges.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = you are not allowed to upload merges
 
 With this error message Gerrit rejects to push a merge commit if the
@@ -12,7 +13,7 @@
 If one of your changes could not be merged in Gerrit due to conflicts
 and you created the merge commit to resolve the conflicts, you might
 want to revert the merge and instead of this do a
-link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[rebase].
+link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[rebase,role=external,window=_blank].
 
 
 GERRIT
diff --git a/Documentation/error-permission-denied.txt b/Documentation/error-permission-denied.txt
index 879273d..fba1d44 100644
--- a/Documentation/error-permission-denied.txt
+++ b/Documentation/error-permission-denied.txt
@@ -1,10 +1,11 @@
+:linkattrs:
 = Permission denied (publickey)
 
 With this error message an SSH command to Gerrit is rejected if the
 SSH authentication is not successful.
 
-The link:http://en.wikipedia.org/wiki/Secure_Shell[SSH] protocol can use
-link:http://en.wikipedia.org/wiki/Public-key_cryptography[Public-key Cryptography]
+The link:http://en.wikipedia.org/wiki/Secure_Shell[SSH,role=external,window=_blank] protocol can use
+link:http://en.wikipedia.org/wiki/Public-key_cryptography[Public-key Cryptography,role=external,window=_blank]
 for authentication.
 In general configurations, Gerrit will authenticate you by the public keys
 known to you. Optionally, it can be configured by the administrator to allow
diff --git a/Documentation/error-push-fails-due-to-commit-message.txt b/Documentation/error-push-fails-due-to-commit-message.txt
index f6e5c1f..3f992f6 100644
--- a/Documentation/error-push-fails-due-to-commit-message.txt
+++ b/Documentation/error-push-fails-due-to-commit-message.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Push fails due to commit message
 
 If Gerrit rejects pushing a commit it is often the case that there is
@@ -6,7 +7,7 @@
 
 If the commit message of the last commit needs to be fixed you can
 simply amend the last commit (please find a detailed description in
-the link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[Git documentation]):
+the link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[Git documentation,role=external,window=_blank]):
 
 ----
   $ git commit --amend
@@ -17,7 +18,7 @@
 rebase for the affected commits. While doing the interactive rebase
 you can e.g. choose 'reword' for those commits for which you want to
 fix the commit messages. For a detailed description of git rebase
-please check the link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation].
+please check the link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation,role=external,window=_blank].
 
 Please use interactive git rebase with care as it rewrites existing
 commits. Generally you should never rewrite commits that have already
diff --git a/Documentation/error-same-change-id-in-multiple-changes.txt b/Documentation/error-same-change-id-in-multiple-changes.txt
index b6aad69..4ff623d 100644
--- a/Documentation/error-same-change-id-in-multiple-changes.txt
+++ b/Documentation/error-same-change-id-in-multiple-changes.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = same Change-Id in multiple changes
 
 With this error message Gerrit rejects to push a commit if it
@@ -64,7 +65,7 @@
 the example above where the last two commits have the same Change-Id,
 this means an interactive rebase for the last two commits should be
 done. For further details about the git rebase command please check
-the link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation for rebase].
+the link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[Git documentation for rebase,role=external,window=_blank].
 
 ----
   $ git rebase -i HEAD~2
@@ -100,7 +101,7 @@
 by using a link:cmd-hook-commit-msg.html[commit hook] or by using EGit) or the Change-Id could be
 removed (not recommended since then amending this commit to create
 subsequent patch sets is more error prone). To change the Change-Id
-of an existing commit do an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase] and fix the
+of an existing commit do an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase,role=external,window=_blank] and fix the
 affected commit messages.
 
 
diff --git a/Documentation/error-upload-denied.txt b/Documentation/error-upload-denied.txt
index 30c5f2d..6638335 100644
--- a/Documentation/error-upload-denied.txt
+++ b/Documentation/error-upload-denied.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Upload denied for project ...
 
 With this error message Gerrit rejects to push a commit if the
@@ -9,7 +10,7 @@
 . contact one of the project owners and request upload permissions
   for the project (access right
   link:access-control.html#category_push['Push'])
-. export your commit as a patch using the link:http://www.kernel.org/pub/software/scm/git/docs/git-format-patch.html[git format-patch] command
+. export your commit as a patch using the link:http://www.kernel.org/pub/software/scm/git/docs/git-format-patch.html[git format-patch,role=external,window=_blank] command
   and provide the patch file to one of the project owners
 
 
diff --git a/Documentation/images/user-review-ui-change-complex-reply-dialogue.png b/Documentation/images/user-review-ui-change-complex-reply-dialogue.png
new file mode 100644
index 0000000..1286852
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-complex-reply-dialogue.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-page-download.png b/Documentation/images/user-review-ui-change-page-download.png
new file mode 100644
index 0000000..63c4ee3
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-page-download.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-page-patchset-dropdown.png b/Documentation/images/user-review-ui-change-page-patchset-dropdown.png
new file mode 100644
index 0000000..f71473e
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-page-patchset-dropdown.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-relation-chain.png b/Documentation/images/user-review-ui-change-relation-chain.png
new file mode 100644
index 0000000..19942f1
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-relation-chain.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-reply-dialogue.png b/Documentation/images/user-review-ui-change-reply-dialogue.png
new file mode 100644
index 0000000..3c95852
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-reply-dialogue.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 77e0ed4..d270f97 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review for Git
 
 == Quickstarts
@@ -9,13 +10,14 @@
 . link:intro-quick.html[Product Overview]
 . link:intro-how-gerrit-works.html[How Gerrit Works]
 . link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
+.. link:intro-gerrit-walkthrough-github.html[Basic Gerrit Walkthrough -- For GitHub Users]
 . link:dev-community.html[Gerrit Community]
 .. link:dev-contributing.html[Contributor Guide]
 
 == Guides
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
-. link:https://source.android.com/source/developing[Default Android Workflow] (external)
+. link:https://source.android.com/source/developing[Default Android Workflow,role=external,window=_blank] (external)
 
 == Tutorials
 . Web
@@ -42,7 +44,7 @@
 . link:access-control.html[Access Controls]
 . Multi-project management
 .. link:user-submodules.html[Submodules]
-.. link:https://source.android.com/source/using-repo.html[Repo] (external)
+.. link:https://source.android.com/source/using-repo.html[Repo,role=external,window=_blank] (external)
 . Prolog rules
 .. link:prolog-cookbook.html[Prolog Cookbook]
 .. link:prolog-change-facts.html[Prolog Facts for Gerrit Changes]
@@ -83,11 +85,11 @@
 
 == Resources
 * link:licenses.html[Licenses and Notices]
-* link:https://www.gerritcodereview.com/[Homepage]
-* link:https://gerrit-releases.storage.googleapis.com/index.html[Downloads]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking]
-* link:https://gerrit.googlesource.com/gerrit[Source Code]
-* link:https://www.gerritcodereview.com/about.md[A History of Gerrit Code Review]
+* link:https://www.gerritcodereview.com/[Homepage,role=external,window=_blank]
+* link:https://gerrit-releases.storage.googleapis.com/index.html[Downloads,role=external,window=_blank]
+* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking,role=external,window=_blank]
+* link:https://gerrit.googlesource.com/gerrit[Source Code,role=external,window=_blank]
+* link:https://www.gerritcodereview.com/about.md[A History of Gerrit Code Review,role=external,window=_blank]
 
 GERRIT
 ------
diff --git a/Documentation/install-j2ee.txt b/Documentation/install-j2ee.txt
index 48751b7..fb78a87 100644
--- a/Documentation/install-j2ee.txt
+++ b/Documentation/install-j2ee.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - J2EE Installation
 
 == Description
@@ -47,7 +48,7 @@
 Download and unzip a release version of Jetty.  From here on we
 call the unpacked directory `$JETTY_HOME`.
 
-* link:http://www.eclipse.org/jetty/downloads.php[Jetty Downloads]
+* link:http://www.eclipse.org/jetty/downloads.php[Jetty Downloads,role=external,window=_blank]
 
 If this is a fresh installation of Jetty, move into the installation
 directory and do some cleanup to remove the sample webapps:
@@ -99,7 +100,7 @@
 
 Excerpt from the
 link:https://tomcat.apache.org/tomcat-7.0-doc/config/systemprops.html[
-documentation]:
+documentation,role=external,window=_blank]:
 
 ----
 Property org.apache.tomcat.util.buf.UDecoder.ALLOW_ENCODED_SLASH:
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 09ebbba..94a576c 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Standalone Daemon Installation Guide
 
 [[prerequisites]]
@@ -5,7 +6,7 @@
 
 To run the Gerrit service, the following requirement must be met on the host:
 
-* JRE, versions 1.8 or 11 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
+* JRE, versions 1.8 or 11 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download,role=external,window=_blank]
 +
 Gerrit is not yet compatible with Java 13 or newer at this time.
 
@@ -22,8 +23,8 @@
 
 . Download the unlimited strength JCE policy files.
 +
-- link:http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html[JDK7 JCE policy files]
-- link:http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html[JDK8 JCE policy files]
+- link:http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html[JDK7 JCE policy files,role=external,window=_blank]
+- link:http://www.oracle.com/technetwork/java/javase/downloads/jce8-download-2133166.html[JDK8 JCE policy files,role=external,window=_blank]
 . Uncompress and extract the downloaded file.
 +
 The downloaded file  contains the following files:
@@ -47,7 +48,7 @@
 
 Current and past binary releases of Gerrit can be obtained from
 the link:https://gerrit-releases.storage.googleapis.com/index.html[
-Gerrit Releases site].
+Gerrit Releases site,role=external,window=_blank].
 
 Download any current `*.war` package. The war will be referred to as
 `gerrit.war` from this point forward, so you may find it easier to
@@ -173,7 +174,7 @@
 
 The `ssh-keygen` command must be available during the init phase to
 generate SSH host keys. If you have
-link:https://git-for-windows.github.io/[Git for Windows] installed,
+link:https://git-for-windows.github.io/[Git for Windows,role=external,window=_blank] installed,
 start Command Prompt and temporary add directory with ssh-keygen to the
 PATH environment variable just before running init command:
 
@@ -197,7 +198,7 @@
 
 To install Gerrit as Windows Service use the
 link:http://commons.apache.org/proper/commons-daemon/procrun.html[Apache
-Commons Daemon Procrun].
+Commons Daemon Procrun,role=external,window=_blank].
 
 Sample install command:
 
@@ -235,7 +236,7 @@
 Gerrit's internal SSH daemon.  See the `git-daemon` documentation
 for details on how to configure this if anonymous access is desired.
 
-* http://www.kernel.org/pub/software/scm/git/docs/git-daemon.html[man git-daemon]
+* http://www.kernel.org/pub/software/scm/git/docs/git-daemon.html[man git-daemon,role=external,window=_blank]
 
 
 [[plugins]]
@@ -246,7 +247,7 @@
 
 == External Documentation Links
 
-* http://www.kernel.org/pub/software/scm/git/docs/git-daemon.html[git-daemon]
+* http://www.kernel.org/pub/software/scm/git/docs/git-daemon.html[git-daemon,role=external,window=_blank]
 
 
 [[backup]]
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
new file mode 100644
index 0000000..f16155b
--- /dev/null
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -0,0 +1,264 @@
+:linkattrs:
+= Basic Gerrit Walkthrough -- For GitHub Users
+
+
+[NOTE]
+====
+This document aims to provide a concise description of the core principles of
+code review in Gerrit for people that were previously using Pull Requests on
+Github or similar concepts. Nothing in this document is meant to state that
+one or the other might be better, but only aims to help new users understand
+Gerrit more readily. We use Github as the point of comparison since it seems
+to be the most popular service.
+====
+
+To illustrate the differences in a meaningful order, we will walk you through
+the process of cloning a repo, making a change, asking for code review,
+iterating on the code and finally having it submitted to the code base. This
+document also does not aim to describe all features of Gerrit. Please refer to
+the link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough] or
+link:index.html[the rest of the documentation] for a more complete overview and additional pointers.
+
+[[tldr]]
+== tl;dr
+
+Here’s how getting code reviewed and submitted with Gerrit is different from
+doing the same with GitHub:
+
+* You need the add a commit-msg hook script when you clone a repo for the first
+time using a snippet you can find e.g. https://gerrit-review.googlesource.com/admin/repos/gerrit[here,role=external,window=_blank];
+* Your review will be on a single commit instead of a branch. You use
+`git commit --amend` to modify a code change.
+* Instead of using the Web UI to create a pull request, you use
+`git push origin HEAD:refs/for/master` to upload new local commits that are
+ready for review to Gerrit. You will find the URL to the review in the output of
+the push command.
+* As a reviewer, Gerrit offers a number of so-called labels to vote on, one of
+which is Code-Review. You indicate a negative, neutral or positive review using
+a -1, 0 or +1 vote.
+* To be able to submit (== merge) a change, you usually need a +2 Code-Review
+vote and possibly additional positive votes, depending on the configuration of
+the project you are contributing to.
+
+[[clone]]
+== 1. Cloning a Repository
+
+[NOTE]
+====
+Both GitHub and Gerrit provide simple Git repository hosting (of course both can
+do much more). In the simplest setup, you could just use both as such without
+any code review to push code. We will assume that this is not what you want to
+do and focus on the use case where your change requires a review.
+====
+
+The first step to working with the code is to clone the repo. For both, Gerrit
+and GitHub, you can simply use the `git clone` command.
+
+For Gerrit, there is an additional step before you can start making changes. For
+reasons we explain below, you’ll have to add a https://gerrit-review.googlesource.com/Documentation/user-changeid.html[commit-msg hook,role=external,window=_blank] script. This will
+append the Gerrit Change-Id to every commit message such that Gerrit can track
+commits through the review process. To make this process a little easier in
+Gerrit, you can find a command snippet for cloning and adding the commit-msg
+hook on the repository page (e.g. https://gerrit-review.googlesource.com/admin/repos/gerrit[here,role=external,window=_blank]).
+
+[[create-change]]
+== 2. Making a Change
+
+*Branches*
+
+Now that you have the code in the git repo on your machine, you can start making
+changes. With GitHub, you would usually create a new branch and then start
+committing to it. This branch would then contain all the changes you share with
+your code reviewers in the next step. Your local branch will usually also be
+pushed to the remote server. This can be handy to back up your work or hand-off
+work to another device or developer.
+
+With Gerrit, you can also create a new local branch to develop in. While not
+required, it can be considered a best practice to sandbox this change from other
+changes you might be making. In contrast to the GitHub model, your local branch
+will not have to be pushed to the remote in Gerrit, at least not for the
+purposes of code review.
+
+*Commits*
+In Gerrit, a single commit is the unit of code that will be reviewed. With
+GitHub, you can commit to your branch as much as you like and the sum of all
+your commits on that branch will get reviewed. As a single commit gets reviewed
+in Gerrit, you need to `git commit --amend` when you iterate on the same change as
+opposed to only using `git commit` with GitHub (see Section 5 for more). You can,
+however, also add another commit on top of your existing commit in Gerrit, which
+will create a second change (and thus another review) that is based on your
+first change. Gerrit will show the relationship between these two changes as a
+so-called relation chain. This also means that your second change can only be
+submitted after the first was successfully merged. In many basic use cases, this
+situation is however not what you want.
+
+image::images/user-review-ui-change-relation-chain.png[Relation chain display on the change page.]
+
+With GitHub, you may be pushing your branch to the remote for non-code-review
+purposes, as mentioned above. You usually do not do this with Gerrit, as
+Gerrit-managed repos often only have one or a few branches on the server that
+can only be merged into via code review.
+
+[[request-review]]
+== 3. Asking for Code Review
+
+After you are satisfied with the changes you made, you’ll usually want/need to
+get your code reviewed. In GitHub, you would push your branch to the remote, go
+to the Web UI and create a pull request. In Gerrit, you need to push your commit
+(or the series of changes/commits) to the remote first, since you usually
+develop in a local branch only. While you can often just use git push with
+GitHub, you need to do a slightly different thing for Gerrit. Gerrit uses a
+“magic” branch that tells the server that this code is supposed to be reviewed.
+To send the changes you made on your local branch to review and being eventually
+merged into the remote’s master branch, you use
+`git push origin HEAD:refs/for/master`. There are also link:user-upload.html#_git_push[a number of Gerrit change
+options] you can trigger from the CLI this way.
+
+After successfully pushing your change to Gerrit, you will already find the URL
+for viewing your change in Gerrit’s Web UI in the response you get from the
+server. The description of the Gerrit code review that was just created is equal
+to the commit message of that one commit the change is based on. In GitHub, you
+might have described your change in the message you can create when creating the
+pull request in the GitHub Web UI.
+
+Next, you would go and visit your Gerrit change in the Web UI to get your change
+ready for review (choose reviewers, cc people, check for failing CI builds or
+tests, etc.), very similar to what you do on Github. Reviewers will be notified
+via email once you add them. By default, anyone can add reviewers to a Gerrit
+change. In GitHub, this ability is reserved for certain users, so you may have
+relied on others adding reviewers for you before. This can be the case in a
+Gerrit project, but it is also often expected that the change owner (usually the
+creator of the change) adds reviewers to get the review process started.
+
+[[reviewing]]
+== 4. Reviewing a Change
+
+Switching perspectives briefly, reviewing a change is fairly similar between
+GitHub and Gerrit. You, as a reviewer, will be notified of a change you have
+been added to via email or see an “incoming” change on your Gerrit dashboard.
+The dashboard is the central overview of changes going on within a Gerrit
+instance. By default, the dashboard shows changes that you are involved in, in
+any way. You can also see all changes on a Gerrit server by using the top menu
+(“Changes” -> “Open”). This view is more similar to what you see on Github, when
+you navigate to the Pull Requests tab of the project/repository you are working
+on. Note, however, that a single Gerrit instance can host multiple projects
+(also referred to as repositories; a list can be found, for example, https://gerrit-review.googlesource.com/admin/repos[here,role=external,window=_blank]). Your
+dashboard and other lists of changes will show all changes across the
+projects/repositories by default.
+
+Back to your dashboard, you can click on the change you want to review. You can
+also access this from the email you received. You will see the same view that
+you saw as an author. In the middle of the change page, you can find the list of
+files that have been modified, just like what you find in the “Files changed”
+tab of GitHub. Also similarly, you can leave comments by highlighting a piece of
+the code and pressing ‘c’. All comments you make are in a draft state and thus
+only visible to you, like on GitHub. When you are done with your review, you
+need to click the “Reply” button at the top of the change page to send your
+assessment to the change owner alongside a “change message” summarizing your
+findings and/or adding higher level comments. Replying to a change makes your
+draft comments and the change message visible on the change page for everyone
+that has view access to this change. This again is fairly similar to GitHub,
+except for Gerrit’s voting labels.
+
+image::images/user-review-ui-change-reply-dialogue.png[Reply dialogue for a Gerrit change.]
+
+As you can see in the screenshot of the reply dialogue, the voting labels are in
+the bottom part of the dialogue. They can be fairly simple as in this case, but
+there can also be a larger number of labels you might be able to vote on. Labels
+can be used to distinguish different aspects of a review (e.g. whether or not
+the licensing of included libraries is okay), outcome of CI systems (e.g.
+whether or not a format checker passed, a build completed successfully, etc.) or
+as a flag that is read by bots to do something with a change. An example of a
+more complex label setup can be seen in this screenshot from the Android Gerrit
+instance.
+
+image::images/user-review-ui-change-complex-reply-dialogue.png[Reply dialogue for a change on the Android project.]
+
+In the simplest case shown above, voting -1 on the Code-Review label equals
+requesting changes on a GitHub pull request, 0 equals just having comments and
++1 means that you think this change looks good. Usually, Gerrit changes require
+a +2 vote on the Code-Review label to be submitted (merged in GitHub terms, see
+Section 6 below). Being able to vote +2 on Code-Review is often restricted to
+maintainers of a given project, so they can have a final say on a change. These
+practices can however vary between projects, as labels and voting permissions
+are configurable.
+
+[[iterate]]
+== 5. Iterating on the Change
+
+After your reviewers got back to you as a change owner, you realize that you
+need to make a few updates to the code in your change. As mentioned in Section 2
+(Making a Change), you’ll have to amend the commit that this review was based
+on. To do that, you might have to checkout the respective commit first if it is
+not at the tip of your local branch, for example if you stacked multiple changes
+on top of each other. Another common use case is to not have a local branch but
+to work in the so-called https://www.git-tower.com/learn/git/faq/detached-head-when-checkout-commit["detached HEAD",role=external,window=_blank] mode. In that case you can use the
+“Download” button on the files tab to copy a command that fetches and checks out
+the commit underlying your change. Make sure to select the latest patchset,
+though!
+
+image::images/user-review-ui-change-page-download.png[Using the “Download” button to copy a command that checks out a given patchset for a change.]
+
+After checking out the commit, you then make the changes as usual. When you
+think you are done, you can commit with the `--amend` flag to change the commit
+you currently have checked out.
+
+When you `git commit --amend` to iterate on your change, you might be worried that
+you are changing your previous commit and may thus lose that state of your work.
+However, here the Change-Id appended to your commit message comes into play.
+While the SHA1 hash of your change (the commit ID used by Git) might change, the
+Change-Id stays the same (in fact it is the SHA1 hash of the very first version
+of that commit). When this amended commit is uploaded to the Gerrit server,
+Gerrit knows that this commit is really an iteration of that previous commit
+(and the associated review) and will preserve both, the old and the new state.
+All previous states of your commit will be visible in the Gerrit UI as so-called
+patchsets (and link:intro-user.html#change-ref[from the Git repo]).
+
+image::images/user-review-ui-change-page-patchset-dropdown.png[Screenshot of the patchset dropdown above the file list, showing all iterations a commit went through.]
+
+After iterating as much as needed, your reviewers will finally be satisfied.
+With GitHub, you would have a string of additional commits in the branch you
+used for opening the pull request. In Gerrit, you still only have that one
+commit in your local branch. All the iterations are available as patchsets in
+the Web UI as well as from the special branch mentioned above.
+
+[[submit]]
+== 6. Submitting a Change
+
+Finally, it is time to submit your change. As mentioned above, the precondition
+for this in Gerrit is usually at least a +2 vote on the Code-Review label. With
+GitHub, an authorized person must have given an “Approve” vote. Once this
+precondition has been met, anyone with submit permission can submit the change
+in Gerrit. To do that, you click the “Submit” button in the Gerrit Web UI just
+as you would click the “Merge Pull Request” button in GitHub. Both, Gerrit and
+GitHub, allow different merge strategies, that can be enabled by project
+administrators. In Gerrit, a merge strategy is configured for each project and
+cannot be changed at submit time while this may be possible with GitHub,
+depending on project configuration.
+
+A merge can fail due to conflicts with competing edits on the target branch.
+With GitHub, you may be able to resolve some simple conflicts directly from the
+Web UI. In Gerrit, you can attempt to rebase a change from the Web UI. If there
+are no conflicts, a new patchset will automatically appear. Otherwise, similar
+to GitHub, you need to resolve conflicts on the command line with your local
+clone of the repository. While you resolve conflicts that arise from a
+`git merge` for GitHub, you will need to link:intro-user.html#rebase[use `git rebase` with your change] on
+Gerrit.
+
+After resolving locally, with GitHub, you end up with another commit on your
+pull request branch and push it to the server, which should then allow you to
+finish merging the change. With Gerrit, resolving the conflict through rebasing
+your commit/change results in another amended version of that same commit and
+you upload it to Gerrit, resulting in a new patchset just like your previous
+iterations addressing reviewer comments. This new patchset will usually require
+another round of reviewer votes, as Gerrit will not copy votes from a previous
+patchset by default.
+
+
+GERRIT
+------
+
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index b4f799c2..92732d0 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Working with Gerrit: An example
 
 To understand how Gerrit works, let's follow a change through its entire
@@ -130,7 +131,7 @@
 while the *Verified* check is done by an automated build server, through a
 mechanism such as the
 link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger
-Jenkins Plugin].
+Jenkins Plugin,role=external,window=_blank].
 
 IMPORTANT: The Code-Review and Verified checks require different permissions
 in Gerrit. This requirement allows teams to separate these tasks. For example,
@@ -186,6 +187,7 @@
 
 * Check out the commit
 * Amend the commit
+* Rebase the commit if needed
 * Push the commit to Gerrit
 
 ----
@@ -195,6 +197,20 @@
 [master 30a6f44] Change to a proper, yeast based pizza dough.
  Date: Fri Jun 8 16:28:23 2018 +0200
  1 file changed, 10 insertions(+), 5 deletions(-)
+----
+
+At this point Max wants to make sure that his change is on top of the branch.
+
+----
+$ git fetch
+$
+----
+
+Max got no output from the fetch command, which is good news.
+This means that the master branch has not progressed and there is no need for link:intro-user.html#rebase[*rebase*].
+Max is now ready to push his change:
+
+----
 $ git push origin HEAD:refs/for/master
 Counting objects: 3, done.
 Delta compression using up to 8 threads.
@@ -237,7 +253,7 @@
 can add custom checks or even remove the Verified check entirely.
 
 Verification is typically an automated process using the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin]
+link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
 or a similar mechanism. However, there are still times when a change requires
 manual verification, or a reviewer needs to check how or if a change works.
 To accommodate these and other similar circumstances, Gerrit exposes each change
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 1f98291..7f1e82b 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Project Owner Guide
 
 This is a Gerrit guide that is dedicated to project owners. It
@@ -181,7 +182,7 @@
 to be prefixed with `ldap/`.
 
 If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/singleusergroup[
-singleusergroup] plugin is installed you can also directly assign
+singleusergroup,role=external,window=_blank] plugin is installed you can also directly assign
 access rights to users, by prefixing the username with `user/` or the
 user's account ID by `userid/`.
 
@@ -374,10 +375,10 @@
 systems. The most commonly used are:
 
 - link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
-  Gerrit Trigger] plugin for link:http://jenkins-ci.org/[Jenkins]
+  Gerrit Trigger,role=external,window=_blank] plugin for link:http://jenkins-ci.org/[Jenkins,role=external,window=_blank]
 
 - link:http://www.mediawiki.org/wiki/Continuous_integration/Zuul[
-  Zuul] for link:http://jenkins-ci.org/[Jenkins]
+  Zuul,role=external,window=_blank] for link:http://jenkins-ci.org/[Jenkins,role=external,window=_blank]
 
 For the integration with the continuous integration system you must
 have a service user that is able to access Gerrit. To create a service
@@ -387,7 +388,7 @@
 a Gerrit administrator to create the service user.
 
 If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/serviceuser[
-serviceuser] plugin is installed you can also create new service users
+serviceuser,role=external,window=_blank] plugin is installed you can also create new service users
 in the Gerrit Web UI under `People` > `Create Service User`. For this
 the `Create Service User` global capability must be assigned.
 
@@ -407,7 +408,7 @@
 
 Gerrit provides an
 link:https://gerrit-review.googlesource.com/Documentation/config-validation.html#new-commit-validation[
-extension point to do validation of new commits]. A Gerrit plugin
+extension point to do validation of new commits,role=external,window=_blank]. A Gerrit plugin
 implementing this extension point can perform validation checks when
 new commits are pushed to Gerrit. The plugin can either provide a
 message to the client or reject the commit and cause the push to fail.
@@ -415,13 +416,13 @@
 There are some plugins available that provide commit validation:
 
 - link:https://gerrit-review.googlesource.com/admin/repos/plugins/uploadvalidator[
-  uploadvalidator]:
+  uploadvalidator,role=external,window=_blank]:
 +
 The `uploadvalidator` plugin allows project owners to configure blocked
 file extensions, required footers and a maximum allowed path length.
 
 - link:https://gerrit-review.googlesource.com/admin/repos/plugins/commit-message-length-validator[
-  commit-message-length-validator]
+  commit-message-length-validator,role=external,window=_blank]
 +
 The `commit-message-length-validator` core plugin validates that commit
 messages conform to line length limits.
@@ -501,9 +502,9 @@
 - Issue Tracker System Plugins
 +
 There are Gerrit plugins for a tight integration with
-link:https://gerrit-review.googlesource.com//admin/repos/plugins/its-jira[Jira],
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[Bugzilla] and
-link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[IBM Rational Team Concert].
+link:https://gerrit-review.googlesource.com//admin/repos/plugins/its-jira[Jira,role=external,window=_blank],
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[Bugzilla,role=external,window=_blank] and
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[IBM Rational Team Concert,role=external,window=_blank].
 If installed, these plugins can e.g. be used to automatically add links
 to Gerrit changes to the issues in the issue tracker system or to
 automatically close an issue if the corresponding change is merged.
@@ -551,15 +552,15 @@
 Gerrit will then notify this person by email about the review request.
 
 With the link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
-reviewers] plugin it is possible to configure default reviewers who
+reviewers,role=external,window=_blank] plugin it is possible to configure default reviewers who
 will be automatically added to each change. The default reviewers can
 be configured in the Gerrit Web UI under `Projects` > `List` >
 <your project> > `General` in the `reviewers Plugin` section.
 
 The link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
-reviewers-by-blame] plugin can automatically add reviewers to changes
+reviewers-by-blame,role=external,window=_blank] plugin can automatically add reviewers to changes
 based on the link:https://www.kernel.org/pub/software/scm/git/docs/git-blame.html[
-git blame] computation on the changed files. This means that the plugin
+git blame,role=external,window=_blank] computation on the changed files. This means that the plugin
 will add those users as reviewer that authored most of the lines
 touched by the change, since these users should be familiar with the
 code and can most likely review the change. How many reviewers the
@@ -578,7 +579,7 @@
 plugins:
 
 - link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
-  download-commands] plugin:
+  download-commands,role=external,window=_blank] plugin:
 +
 The `download-commands` plugin provides the default download commands
 (`Checkout`, `Cherry Pick`, `Format Patch` and `Pull`).
@@ -587,14 +588,14 @@
 the change screen.
 
 - link:https://gerrit-review.googlesource.com/admin/repos/plugins/egit[
-  egit] plugin:
+  egit,role=external,window=_blank] plugin:
 +
 The `egit` plugin provides the change ref as a download command, which is
 needed for downloading a change from within
 link:https://www.eclipse.org/egit/[EGit].
 
 - link:https://gerrit-review.googlesource.com/admin/repos/plugins/project-download-commands[
-  project-download-commands] plugin:
+  project-download-commands,role=external,window=_blank] plugin:
 +
 The `project-download-commands` plugin enables project owners to
 configure project-specific download commands. For example, a
@@ -678,14 +679,14 @@
 contains this history. If your existing codebase is in another VCS you
 must migrate it to Git first. For Subversion you can use the
 link:http://git-scm.com/book/en/Git-and-Other-Systems-Git-and-Subversion[
-git svn] command as described in the
+git svn,role=external,window=_blank] command as described in the
 link:http://git-scm.com/book/en/Git-and-Other-Systems-Migrating-to-Git#Subversion[
-Subversion migration guide]. An importer for Perforce is available in
+Subversion migration guide,role=external,window=_blank]. An importer for Perforce is available in
 the `contrib` section of the Git source code; how to use
-link:http://git-scm.com/docs/git-p4[git p4] to do the import from
+link:http://git-scm.com/docs/git-p4[git p4,role=external,window=_blank] to do the import from
 Perforce is described in the
 link:http://git-scm.com/book/en/Git-and-Other-Systems-Migrating-to-Git#Perforce[
-Perforce migration guide].
+Perforce migration guide,role=external,window=_blank].
 
 To import an existing history into a Gerrit project you bypass code
 review and push it directly to `refs/heads/<branch>`. For this you must
@@ -699,7 +700,7 @@
 link:access-control.html#category_forge_committer[Forge Committer]
 access right globally. In this case you must use the
 link:https://www.kernel.org/pub/software/scm/git/docs/git-filter-branch.html[
-git filter-branch] command to rewrite the committer information for all
+git filter-branch,role=external,window=_blank] command to rewrite the committer information for all
 commits (the author information that records who was writing the code
 stays intact; signed tags will lose their signature):
 
@@ -741,7 +742,7 @@
 Gerrit core does not support the deletion of projects.
 
 If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
-delete-project] plugin is installed, projects can be deleted from the
+delete-project,role=external,window=_blank] plugin is installed, projects can be deleted from the
 Gerrit Web UI under `Projects` > `List` > <project> > `General` by
 clicking on the `Delete` command under `Project Commands`. The `Delete`
 command is only available if you have the `Delete Projects` global
@@ -769,7 +770,7 @@
 history (changes, review comments) is lost.
 
 Alternatively, you can use the
-link:https://gerrit.googlesource.com/plugins/importer/[importer] plugin
+link:https://gerrit.googlesource.com/plugins/importer/[importer,role=external,window=_blank] plugin
 to copy the project _including the review history_, and then
 link:#project-deletion[delete the old project].
 
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index 11d5052..b8670ed 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -1,7 +1,8 @@
+:linkattrs:
 = Gerrit Code Review Product Overview
 
 Gerrit Code Review is a web-based code review tool built on
-https://git-scm.com/[Git version control].
+https://git-scm.com/[Git version control,role=external,window=_blank].
 
 == What is Gerrit Code Review?
 
@@ -45,7 +46,7 @@
 
 . link:intro-user.html[User Guide]
 . link:intro-project-owner.html[Project Owner Guide]
-. link:https://source.android.com/source/life-of-a-patch[Default Android Workflow] (external)
+. link:https://source.android.com/source/life-of-a-patch[Default Android Workflow,role=external,window=_blank] (external)
 
 GERRIT
 ------
diff --git a/Documentation/intro-rockstar.txt b/Documentation/intro-rockstar.txt
index 0b67950..4a7167b 100644
--- a/Documentation/intro-rockstar.txt
+++ b/Documentation/intro-rockstar.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Use Gerrit to Be a Rockstar Programmer
 
 == Overview
@@ -42,7 +43,7 @@
 
 * git cherry-pick
 
-* link:https://www.kernel.org/pub/software/scm/git/docs/git-bisect-lk2009.html[git bisect]
+* link:https://www.kernel.org/pub/software/scm/git/docs/git-bisect-lk2009.html[git bisect,role=external,window=_blank]
 
 
 [[amending]]
@@ -59,8 +60,8 @@
 
 At least two well-known open source projects insist on these practices:
 
-* link:http://git-scm.com/[Git]
-* link:http://www.kernel.org/category/about.html[Linux Kernel]
+* link:http://git-scm.com/[Git,role=external,window=_blank]
+* link:http://www.kernel.org/category/about.html[Linux Kernel,role=external,window=_blank]
 
 However, contributors to these projects don’t refine and polish their changes
 in private until they’re perfect. Instead, polishing code is part of a review
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 16929ae..9dd58b8 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -1,10 +1,11 @@
+:linkattrs:
 = User Guide
 
 This is a Gerrit guide that is dedicated to Gerrit end-users. It
 explains the standard Gerrit workflows and how a user can adapt Gerrit
 to personal preferences.
 
-It is expected that readers know about link:http://git-scm.com/[Git]
+It is expected that readers know about link:http://git-scm.com/[Git,role=external,window=_blank]
 and that they are familiar with basic git commands and workflows.
 
 [[gerrit]]
@@ -21,20 +22,20 @@
 
 Gerrit uses the git protocol. This means in order to work with Gerrit
 you do *not* need to install any Gerrit client, but having a regular
-git client, such as the link:http://git-scm.com/[git command line] or
-link:http://eclipse.org/egit/[EGit] in Eclipse, is sufficient.
+git client, such as the link:http://git-scm.com/[git command line,role=external,window=_blank] or
+link:http://eclipse.org/egit/[EGit,role=external,window=_blank] in Eclipse, is sufficient.
 
 Still there are some client-side tools for Gerrit, which can be used
 optionally:
 
-* link:http://eclipse.org/mylyn/[Mylyn Gerrit Connector]: Gerrit
+* link:http://eclipse.org/mylyn/[Mylyn Gerrit Connector,role=external,window=_blank]: Gerrit
   integration with Mylyn
 * link:https://github.com/uwolfer/gerrit-intellij-plugin[Gerrit
-  IntelliJ Plugin]: Gerrit integration with the
-  link:http://www.jetbrains.com/idea/[IntelliJ Platform]
+  IntelliJ Plugin,role=external,window=_blank]: Gerrit integration with the
+  link:http://www.jetbrains.com/idea/[IntelliJ Platform,role=external,window=_blank]
 * link:https://play.google.com/store/apps/details?id=com.jbirdvegas.mgerrit[
-  mGerrit]: Android client for Gerrit
-* link:https://github.com/stackforge/gertty[Gertty]: Console-based
+  mGerrit,role=external,window=_blank]: Android client for Gerrit
+* link:https://github.com/stackforge/gertty[Gertty,role=external,window=_blank]: Console-based
   interface for Gerrit
 
 [[clone]]
@@ -209,7 +210,7 @@
 Instead of manually installing the `commit-msg` hook for each git
 repository, you can copy it into the
 link:http://git-scm.com/docs/git-init#_template_directory[git template
-directory]. Then it is automatically copied to every newly cloned
+directory,role=external,window=_blank]. Then it is automatically copied to every newly cloned
 repository.
 
 [[review-change]]
@@ -452,6 +453,15 @@
 Abandoned changes can be link:user-review-ui.html#restore[restored] if
 later they are needed again.
 
+[[cherrypickof]]
+== Cherry-Pick changes of a Change
+
+When a change is created/updated using the 'cherry-pick' functionalty,
+the original change and patchset details are recorded in the Change's
+cherrypick field. This field cannot be set or updated by the user in
+any way. It is set automatically after the cherry-pick operation completes
+successfully.
+
 [[topics]]
 == Using Topics
 
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
index 030541d..893ab36 100644
--- a/Documentation/js-api.txt
+++ b/Documentation/js-api.txt
@@ -150,6 +150,13 @@
 parameter the URL of the plugin is returned. If passed a string
 the argument is appended to the plugin URL.
 
+A plugin's URL is where this plugin is loaded, it doesn't
+necessary to be the same as the Gerrit host. Use `window.location`
+if you need to access the Gerrit host info.
+
+For preloaded plugins, the plugin url is based on a global
+configuration of where to load all plugins, default to current host.
+
 [source,javascript]
 ----
 self.url();                    // "https://gerrit-review.googlesource.com/plugins/demo/"
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 179633c..f63c1b4 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -65,7 +65,6 @@
 * httpcomponents:httpcore
 * httpcomponents:httpcore-nio
 * jackson:jackson-core
-* jetty:continuation
 * jetty:http
 * jetty:io
 * jetty:jmx
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index 0d8848e..aafaf58 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Quickstart for Installing Gerrit on Linux
 
 This content explains how to install a basic instance of Gerrit on a Linux
@@ -29,7 +30,7 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases]. The steps below install Gerrit 3.0.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.0.3:
 
 ....
 wget https://gerrit-releases.storage.googleapis.com/gerrit-3.0.3.war
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index b373129..236792b 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -141,10 +141,10 @@
 
 === NoteDb
 
-* `notedb/update_latency`: NoteDb update latency by table.
-* `notedb/stage_update_latency`: Latency for staging updates to NoteDb by table.
-* `notedb/read_latency`: NoteDb read latency by table.
-* `notedb/parse_latency`: NoteDb parse latency by table.
+* `notedb/update_latency`: NoteDb update latency for changes.
+* `notedb/stage_update_latency`: Latency for staging change updates to NoteDb.
+* `notedb/read_latency`: NoteDb read latency for changes.
+* `notedb/parse_latency`: NoteDb parse latency for changes.
 * `notedb/external_id_cache_load_count`: Total number of times the external ID
   cache loader was called.
 * `notedb/external_id_partial_read_latency`: Latency for generating a new external ID
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 8725cee..0505dd2 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - NoteDb Backend
 
 NoteDb is the next generation of Gerrit storage backend, which replaces the
@@ -36,7 +37,7 @@
   the upgrade process by running `gerrit.war init`
 - Account, group and change metadata on the servers behind `googlesource.com` is fully
   migrated to NoteDb. In other words, if you use
-  link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
+  link:https://gerrit-review.googlesource.com/[gerrit-review,role=external,window=_blank], you're already
   using NoteDb.
 - NoteDb is the only database format supported by Gerrit 3.0. The change data
   migration tools are only included in Gerrit 2.15 and 2.16; they are not
@@ -197,5 +198,5 @@
 
 In case of rollback from NoteDB to ReviewDB, all the meta refs and the
 sequence ref need to be removed.
-The [remove-notedb-refs.sh](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh)
+The [remove-notedb-refs.sh,role=external,window=_blank](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh)
 script has been written to automate this process.
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index d901851..fadf5d4 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -1,8 +1,9 @@
+:linkattrs:
 = Gerrit Code Review - PolyGerrit Plugin Development
 
 CAUTION: Work in progress. Hard hat area. Please
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
-feedback] if something's not right.
+feedback,role=external,window=_blank] if something's not right.
 
 For migrating existing GWT UI plugins, please check out the
 link:pg-plugin-migration.html#migration[migration guide].
@@ -11,7 +12,7 @@
 == Plugin loading and initialization
 
 link:js-api.html#_entry_point[Entry point] for the plugin and the loading method
-is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports]
+is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports,role=external,window=_blank]
 spec.
 
 * The plugin provides pluginname.html, and can be a standalone file or a static
@@ -103,7 +104,7 @@
 
 A plugin may provide Polymer's
 https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
-modules] to style individual endpoints using
+modules,role=external,window=_blank] to style individual endpoints using
 `plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
 as a standalone `<dom-module>` defined in the same .html file.
 
@@ -152,7 +153,7 @@
 
 Alternative for
 link:https://www.polymer-project.org/1.0/docs/devguide/data-binding[Polymer data
-binding] for plugins that don't use Polymer. Can be used to bind element
+binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
 See `samples/bind-parameters.html` for examples on both Polymer data bindings
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
index 3ddceed..bca4b7a 100644
--- a/Documentation/pg-plugin-migration.txt
+++ b/Documentation/pg-plugin-migration.txt
@@ -1,8 +1,9 @@
+:linkattrs:
 = Gerrit Code Review - PolyGerrit Plugin Development
 
 CAUTION: Work in progress. Hard hat area. Please
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
-feedback] if something's not right.
+feedback,role=external,window=_blank] if something's not right.
 
 [[migration]]
 == Incremental migration of existing GWT UI plugins
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
index 2453bad..f600376 100644
--- a/Documentation/pg-plugin-styling.txt
+++ b/Documentation/pg-plugin-styling.txt
@@ -1,20 +1,21 @@
+:linkattrs:
 = Gerrit Code Review - PolyGerrit Plugin Styling
 
 == Plugin styles
 
 Plugins may provide
 link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer
-style modules] for UI CSS-based customization.
+style modules,role=external,window=_blank] for UI CSS-based customization.
 
 PolyGerrit UI implements number of styling endpoints, which apply CSS mixins
-link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply] to its
+link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply,role=external,window=_blank] to its
 direct contents.
 
 NOTE: Only items (i.e. CSS properties and mixin targets) documented here are
 guaranteed to work in the long term, since they are covered by integration
 tests. + When there is a need to add new property or endpoint, please
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file
-a bug] stating your use case to track and maintain for future releases.
+a bug,role=external,window=_blank] stating your use case to track and maintain for future releases.
 
 Plugins should be html-based and imported following PolyGerrit's
 link:pg-plugin-dev.html#loading[dev guide].
@@ -64,7 +65,7 @@
 
 Following CSS properties have
 link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term
-support via integration test]:
+support via integration test,role=external,window=_blank]:
 
 * `display`
 +
diff --git a/Documentation/pgm-MigrateAccountPatchReviewDb.txt b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
index c8ab193..64a1008 100644
--- a/Documentation/pgm-MigrateAccountPatchReviewDb.txt
+++ b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = MigrateAccountPatchReviewDb
 
 == NAME
@@ -30,7 +31,7 @@
 [NOTE]
 When using MySQL, the file_name column length in the account_patch_reviews table will be shortened
 from the standard 4096 characters down to 255 characters. This is due to a
-link:https://dev.mysql.com/doc/refman/5.7/en/innodb-restrictions.html[MySQL limitation]
+link:https://dev.mysql.com/doc/refman/5.7/en/innodb-restrictions.html[MySQL limitation,role=external,window=_blank]
 on the max size of 767 bytes for each column in an index.
 
 == OPTIONS
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index f291920..21b8c9f 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Prolog Submit Rules Cookbook
 
 [[SubmitRule]]
@@ -23,10 +24,10 @@
 link:config-gerrit.html#_a_id_rules_a_section_rules[rules section])
 
 link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
-discussion thread] explains why Prolog was chosen for the purpose of writing
+discussion thread,role=external,window=_blank] explains why Prolog was chosen for the purpose of writing
 project specific submit rules.
 link:http://gerrit-documentation.googlecode.com/svn/ReleaseNotes/ReleaseNotes-2.2.2.html[Gerrit
-2.2.2 ReleaseNotes] introduces Prolog support in Gerrit.
+2.2.2 ReleaseNotes,role=external,window=_blank] introduces Prolog support in Gerrit.
 
 [[SubmitType]]
 == Submit Type
@@ -58,14 +59,14 @@
 
 == Prolog Language
 This document is not a complete Prolog tutorial.
-link:http://en.wikipedia.org/wiki/Prolog[This Wikipedia page on Prolog] is a
+link:http://en.wikipedia.org/wiki/Prolog[This Wikipedia page on Prolog,role=external,window=_blank] is a
 good starting point for learning the Prolog language. This document will only
 explain some elements of Prolog that are necessary to understand the provided
 examples.
 
 == Prolog in Gerrit
-Gerrit uses its own link:https://gerrit.googlesource.com/prolog-cafe/[fork] of the
-original link:http://kaminari.istc.kobe-u.ac.jp/PrologCafe/[prolog-cafe]
+Gerrit uses its own link:https://gerrit.googlesource.com/prolog-cafe/[fork,role=external,window=_blank] of the
+original link:http://kaminari.istc.kobe-u.ac.jp/PrologCafe/[prolog-cafe,role=external,window=_blank]
 project. Gerrit embeds the prolog-cafe library and can interpret Prolog programs
 at runtime.
 
@@ -75,7 +76,7 @@
 Prolog interpreter shell.
 
 For batch or unit tests, see the examples in Gerrit source directory
-link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/prologtests/examples/[prologtests/examples].
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/prologtests/examples/[prologtests/examples,role=external,window=_blank].
 
 [NOTE]
 The interactive shell is just a prolog shell, it does not load
@@ -84,7 +85,7 @@
 
 == SWI-Prolog
 Instead of using the link:pgm-prolog-shell.html[prolog-shell] program one can
-also use the link:http://www.swi-prolog.org/[SWI-Prolog] environment. It
+also use the link:http://www.swi-prolog.org/[SWI-Prolog,role=external,window=_blank] environment. It
 provides a better shell interface and a graphical source-level debugger.
 
 [[RulesFile]]
diff --git a/Documentation/quota.txt b/Documentation/quota.txt
index a647e33..475ae72 100644
--- a/Documentation/quota.txt
+++ b/Documentation/quota.txt
@@ -1,9 +1,10 @@
+:linkattrs:
 = Gerrit Code Review - Quota
 
 Gerrit does not provide out of the box quota enforcement. However, it does
 support an extension mechanism for plugins to hook into to provide this
 functionality. The most prominent plugin is the
-link:https://gerrit.googlesource.com/plugins/quota/[Quota Plugin].
+link:https://gerrit.googlesource.com/plugins/quota/[Quota Plugin,role=external,window=_blank].
 
 This documentation is intended to be read by plugin developers. It contains all
 quota requests implemented in Gerrit-core as well as the metadata that they have
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index beefe8b..bb83d95 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - /accounts/ REST API
 
 This page describes the account related REST endpoints.
@@ -2379,13 +2380,18 @@
 The `ContributorAgreementInfo` entity contains information about a
 contributor agreement.
 
-[options="header",cols="1,6"]
-|=================================
-|Field Name                 |Description
-|`name`                     |The name of the agreement.
-|`description`              |The description of the agreement.
-|`url`                      |The URL of the agreement.
-|=================================
+[options="header",cols="1,^1,5"]
+|================================
+|Field Name         ||Description
+|`name`             ||The unique name of the contributor agreement.
+|`description`      ||The description of the contributor agreement.
+|`url`              ||The URL of the contributor agreement.
+|`auto_verify_group`|optional|
+The group to which a user that signs the contributor agreement online
+is added automatically as a link:rest-api-groups.html#group-info[
+GroupInfo] entity. If not set, users cannot sign the contributor
+agreement online.
+|================================
 
 [[contributor-agreement-input]]
 === ContributorAgreementInput
@@ -2422,8 +2428,8 @@
 |Field Name                 |Description
 |`change`                   |
 link:rest-api-changes.html#change-info[ChangeInfo] entity describing the change
-on which one or more comments was deleted. Populated with only the
-link:rest-api-changes.html#skip_mergeable[SKIP_MERGEABLE] option.
+on which one or more comments was deleted. Populated with no change list
+options.
 |`deleted`                  |
 List of link:rest-api-changes.html#comment-info[CommentInfo] entities for each
 comment that was deleted.
@@ -2640,7 +2646,7 @@
 |`id`         |Not set in map context|The 8-char hex GPG key ID.
 |`fingerprint`|Not set for deleted keys|The 40-char (plus spaces) hex GPG key fingerprint.
 |`user_ids`   |Not set for deleted keys|
-link:https://tools.ietf.org/html/rfc4880#section-5.11[OpenPGP User IDs]
+link:https://tools.ietf.org/html/rfc4880#section-5.11[OpenPGP User IDs,role=external,window=_blank]
 associated with the public key.
 |`key`        |Not set for deleted keys|ASCII armored public key material.
 |`status`     |Not set for deleted keys|
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 2561f4f..0f36763 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - /changes/ REST API
 
 This page describes the change related REST endpoints.
@@ -72,7 +73,9 @@
 Queries changes visible to the caller. The
 link:user-search.html#_search_operators[query string] must be provided
 by the `q` parameter. The `n` parameter can be used to limit the
-returned results.
+returned results. The `no-limit` parameter can be used remove the default
+limit on queries and return all results. This might not be supported by
+all index backends.
 
 As result a list of link:#change-info[ChangeInfo] entries is returned.
 The change output is sorted by the last update time, most recently
@@ -303,21 +306,6 @@
     from the change owner, i.e. this change would show up in the results of
     link:user-search.html#reviewedby[reviewedby:self].
 --
-
-[[skip_mergeable]]
---
-* `SKIP_MERGEABLE`: skip the `mergeable` field in
-link:#change-info[ChangeInfo]. For fast moving projects, this field must
-be recomputed often, which is slow for projects with big trees.
-+
-When link:config-gerrit.html#change.api.excludeMergeableInChangeInfo[
-`change.api.excludeMergeableInChangeInfo`] is set in the `gerrit.config`,
-the `mergeable` field will always be omitted and `SKIP_MERGEABLE` has no
-effect.
-+
-A change's mergeability can be requested separately by calling the
-link:#get-mergeable[get-mergeable] endpoint.
---
 [[skip_diffstat]]
 --
 * `SKIP_DIFFSTAT`: skip the 'insertions' and 'deletions' field in link:#change-info[ChangeInfo].
@@ -363,11 +351,6 @@
   as link:#tracking-id-info[TrackingIdInfo].
 --
 
-[[no-limit]]
---
-* `NO-LIMIT`: Return all results
---
-
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -1451,6 +1434,10 @@
 
 Reverts a change.
 
+The subject of the newly created change will be
+'Revert "<subject-of-reverted-change>"'. If the subject of the change reverted is
+above 63 characters, it will be cut down to 59 characters with "..." in the end.
+
 The request body does not need to include a link:#revert-input[
 RevertInput] entity if no review comment is added.
 
@@ -1501,6 +1488,115 @@
   change is new
 ----
 
+[[revert-submission]]
+=== Revert Submission
+--
+'POST /changes/link:#change-id[\{change-id\}]/revert_submission'
+--
+
+Creates open revert changes for all of the changes of a certain submission.
+
+The subject of each revert change will be 'Revert "<subject-of-reverted-change"'.
+If the subject is above 60 characters, the subject will be cut to 56 characters
+with "..." in the end. However, whenever reverting the submission of a revert
+submission, the subject will be shortened from
+'Revert "Revert "<subject-of-reverted-change""' to
+'Revert^2 "<subject-of-reverted-change"'. Also, for every future revert submission,
+the only difference in the subject will be the number of the revert (instead of
+Revert^2 the subject will change to Revert^3 and so on).
+There are no guarantees about the subjects if the users change the default subjects.
+
+Details for the revert can be specified in the request body inside a link:#revert-input[
+RevertInput] The topic of all created revert changes will be
+`revert-{submission_id}-{random_string_of_size_10}`.
+
+The changes will not be rebased on onto the destination branch so the users may still
+have to manually rebase them to resolve conflicts and make them submittable.
+
+However, the changes that have the same project and branch will be rebased on top
+of each other. E.g, the first revert change will have the original change as a
+parent, and the second revert change will have the first revert change as a
+parent.
+
+There is one special case that involves merge commits; if a user has multiple
+changes in the same project and branch, but not in the same change series, those
+changes can still get submitted together if they have the same topic and
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`] in
+gerrit.config is set to true. In the case, Gerrit may create merge commits on
+submit (depending on the link:config-project-config.html#submit-type[submit types]
+of the project).
+The first parent for the reverts will be the most recent merge commit that was
+created by Gerrit to merge the different change series into the target branch.
+
+.Request
+----
+  POST /changes/myProject~master~I1ffe09a505e25f15ce1521bcfb222e51e62c2a14/revert_submission HTTP/1.0
+----
+
+As response link:#revert-submission-info[RevertSubmissionInfo] entity
+is returned. That entity describes the revert changes.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  "revert_changes":
+    [
+      {
+        "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+        "project": "myProject",
+        "branch": "master",
+        "topic": "revert--1571043962462-3640749-ABCEEZGHIJ",
+        "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+        "subject": "Revert \"Implementing Feature X\"",
+        "status": "NEW",
+        "created": "2013-02-01 09:59:32.126000000",
+        "updated": "2013-02-21 11:16:36.775000000",
+        "mergeable": true,
+        "insertions": 6,
+        "deletions": 4,
+        "_number": 3965,
+        "owner": {
+          "name": "John Doe"
+        }
+      },
+      {
+        "id": "anyProject~master~1eee2c9d8f352483781e772f35dc586a69ff5646",
+        "project": "anyProject",
+        "branch": "master",
+        "topic": "revert--1571043962462-3640749-ABCEEZGHIJ",
+        "change_id": "I1eee2c9d8f352483781e772f35dc586a69ff5646",
+        "subject": "Revert \"Implementing Feature Y\"",
+        "status": "NEW",
+        "created": "2013-02-04 09:59:33.126000000",
+        "updated": "2013-02-21 11:16:37.775000000",
+        "mergeable": true,
+        "insertions": 62,
+        "deletions": 11,
+        "_number": 3966,
+        "owner": {
+          "name": "Jane Doe"
+        }
+      }
+    ]
+----
+
+If any of the changes cannot be reverted because the change state doesn't
+allow reverting the change, the response is "`409 Conflict`" and
+the error message is contained in the response body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  change is new
+----
+
 [[submit-change]]
 === Submit Change
 --
@@ -2267,7 +2363,7 @@
 A message can be specified in the request body inside a
 link:#private-input[PrivateInput] entity. Historically, this method allowed
 a body in the DELETE, but that behavior is
-link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated,role=external,window=_blank].
 In this case, use a POST request instead:
 
 .Request
@@ -3253,7 +3349,7 @@
 Options can be provided in the request body as a
 link:#delete-reviewer-input[DeleteReviewerInput] entity.
 Historically, this method allowed a body in the DELETE, but that behavior is
-link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated,role=external,window=_blank].
 In this case, use a POST request instead:
 
 .Request
@@ -3333,7 +3429,7 @@
 Options can be provided in the request body as a
 link:#delete-vote-input[DeleteVoteInput] entity.
 Historically, this method allowed a body in the DELETE, but that behavior is
-link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated,role=external,window=_blank].
 In this case, use a POST request instead:
 
 .Request
@@ -3797,6 +3893,10 @@
 The review must be provided in the request body as a
 link:#review-input[ReviewInput] entity.
 
+If the labels are set, the user sending the request will automatically be
+added as a reviewer, otherwise (if they only commented) they are added to
+the CC list.
+
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
 
@@ -4706,7 +4806,7 @@
 Deletion reason can be provided in the request body as a
 link:#delete-comment-input[DeleteCommentInput] entity.
 Historically, this method allowed a body in the DELETE, but that behavior is
-link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated,role=external,window=_blank].
 In this case, use a POST request instead:
 
 .Request
@@ -5266,6 +5366,17 @@
 The `context` parameter can be specified to control the number of lines of surrounding context
 in the diff.  Valid values are `ALL` or number of lines.
 
+[[preview-fix]]
+=== Preview fix
+--
+'GET /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/preview'
+--
+
+Gets the diffs of all files for a certain <<fix-id,\{fix-id\}>>.
+As response, a map of link:#diff-info[DiffInfo] entities is returned that describes the diffs.
+
+Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id and a virtual patch set with the applied fix.
+
 [[get-blame]]
 === Get Blame
 --
@@ -5526,7 +5637,7 @@
 Options can be provided in the request body as a
 link:#delete-vote-input[DeleteVoteInput] entity.
 Historically, this method allowed a body in the DELETE, but that behavior is
-link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated,role=external,window=_blank].
 In this case, use a POST request instead:
 
 .Request
@@ -5833,10 +5944,9 @@
 Not set for merged changes.
 |`mergeable`          |optional|
 Whether the change is mergeable. +
-Not set for merged changes, if the change has not yet been tested, or
-if the link:#skip_mergeable[skip_mergeable] option is set or when
-link:config-gerrit.html#change.api.excludeMergeableInChangeInfo[change.api.excludeMergeableInChangeInfo]
-is set.
+Only set for open changes if
+link:config-gerrit.html#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior]
+is `API_REF_UPDATED_AND_CHANGE_REINDEX`.
 |`submittable`        |optional|
 Whether the change has been approved by the project submit rules. +
 Only set if link:#submittable[requested].
@@ -5926,6 +6036,12 @@
 When present, change has been marked Ready at some point in time.
 |`revert_of`          |optional|
 The numeric Change-Id of the change that this change reverts.
+|`submission_id`      |optional|
+ID of the submission of this change. Only set if the status is `MERGED`.
+This ID is equal to the numeric ID of the change that triggered the submission.
+If the change that triggered the submission also has a topic, it will be
+"<id>-<topic>" of the change that triggered the submission.
+The callers must not rely on the format of the submission ID.
 |==================================
 
 [[change-input]]
@@ -6690,6 +6806,11 @@
 The source to merge from, e.g. a complete or abbreviated commit SHA-1,
 a complete reference name, a short reference name under `refs/heads`, `refs/tags`,
 or `refs/remotes` namespace, etc.
+|`source_branch`  |optional|
+A branch from which `source` is reachable. If specified,
+`source` is checked for visibility and reachability against only this
+branch. This speeds up the operation, especially for large repos with
+many branches.
 |`strategy`     |optional|
 The strategy of the merge, can be `recursive`, `resolve`,
 `simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
@@ -6931,10 +7052,24 @@
 Additional information about whom to notify about the revert as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |`topic`         |optional|
-Name of the topic for the revert change. If not set, the default is the topic
-of the change being reverted.
+Name of the topic for the revert change. If not set, the default for Revert
+endpoint is the topic of the change being reverted, and the default for the
+RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 |=============================
 
+[[revert-submission-info]]
+=== RevertSubmissionInfo
+The `RevertSubmissionInfo` entity describes the revert changes.
+
+[options="header",cols="1,6"]
+|==============================
+|Field Name       | Description
+|`revert_changes` |
+A list of link:#change-info[ChangeInfo] that describes the revert changes. Each
+entity in that list is a revert change that was created in that revert
+submission.
+|==============================
+
 [[review-info]]
 === ReviewInfo
 The `ReviewInfo` entity contains information about a review.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 063e54d..828674f 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1566,10 +1566,26 @@
 the whole topic is submitted].
 |`disable_private_changes` |not set if `false`|
 Returns true if private changes are disabled.
-|`exclude_mergeable_in_change_info` |not set if `false`|
-Value of the link:config-gerrit.html#change.api.excludeMergeableInChangeInfo[
+|`mergeability_computation_behavior` ||
+Value of the link:config-gerrit.html#change.mergeabilityComputationBehavior[
 configuration parameter] that controls whether the mergeability bit in
-link:rest-api-changes.html#change-info[ChangeInfo] will never be set.
+link:rest-api-changes.html#change-info[ChangeInfo] will never be set and if the
+bit is indexed.
+|=============================
+
+[[change-index-config-info]]
+=== ChangeIndexConfigInfo
+The `ChangeIndexConfigInfo` entity contains information about Gerrit
+configuration from the link:config-gerrit.html#index.change[index.change]
+section.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name           ||Description
+|`index_mergeable`        |not set if `false`|
+Value of the link:config-gerrit.html#index.change.indexMergeable[
+configuration parameter] that controls whether the mergeability bit is
+indexed (hence queryable using `is:mergeable`).
 |=============================
 
 [[check-account-external-ids-input]]
@@ -1821,6 +1837,21 @@
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
 |=================================
 
+[[index-config-info]]
+=== IndexConfigInfo
+The `IndexConfigInfo` entity contains information about Gerrit
+configuration from the link:config-gerrit.html#index[index]
+section.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name           ||Description
+|`change`                  ||
+Information about the configuration from the
+link:config-gerrit.html#index.change[index.change] section as
+link:#index.change[ChangeIndexConfigInfo] entity.
+|=============================
+
 [[hit-ration-info]]
 === HitRatioInfo
 The `HitRatioInfo` entity contains information about the hit ratio of a
@@ -1947,6 +1978,10 @@
 Information about the configuration from the
 link:config-gerrit.html#gerrit[gerrit] section as link:#gerrit-info[
 GerritInfo] entity.
+|`index`                  ||
+Information about the configuration from the
+link:config-gerrit.html#index[index] section as link:#index[
+IndexConfigInfo] entity.
 |`note_db_enabled`         |not set if `false`|
 Whether the NoteDb storage backend is fully enabled.
 |`plugin`                  ||
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 00fd81f..a99c3bb 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -341,24 +341,19 @@
 [[query-groups]]
 === Query Groups
 --
-'GET /groups/?query2=<query>'
+'GET /groups/?query=<query>'
 --
 
 Queries internal groups visible to the caller. The
 link:user-search-groups.html#_search_operators[query string] must be
-provided by the `query2` parameter. The `start` and `limit` parameters
+provided by the `query` parameter. The `start` and `limit` parameters
 can be used to skip/limit results.
 
 As result a list of link:#group-info[GroupInfo] entities is returned.
 
-[NOTE] `query2` is a temporary name and in future this option may be
-renamed to `query`. `query2` was chosen to maintain backwards
-compatibility with the deprecated `query` parameter on the
-link:#list-groups[List Groups] endpoint.
-
 .Request
 ----
-  GET /groups/?query2=inname:test HTTP/1.0
+  GET /groups/?query=inname:test HTTP/1.0
 ----
 
 .Response
@@ -398,12 +393,12 @@
 
 [[group-query-limit]]
 ==== Group Limit
-The `/groups/?query2=<query>` URL also accepts a limit integer in the
+The `/groups/?query=<query>` URL also accepts a limit integer in the
 `limit` parameter. This limits the results to `limit` groups.
 
 Query the first 25 groups in group list.
 ----
-  GET /groups/?query2=<query>&limit=25 HTTP/1.0
+  GET /groups/?query=<query>&limit=25 HTTP/1.0
 ----
 
 The `/groups/` URL also accepts a start integer in the `start`
@@ -411,7 +406,7 @@
 
 Query 25 groups starting from index 50.
 ----
-  GET /groups/?query2=<query>&limit=25&start=50 HTTP/1.0
+  GET /groups/?query=<query>&limit=25&start=50 HTTP/1.0
 ----
 
 [[group-query-options]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index c1349aa..6fbb338 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - /projects/ REST API
 
 This page describes the project related REST endpoints.
@@ -610,7 +611,7 @@
 A commit message can be provided in the request body as a
 link:#project-description-input[ProjectDescriptionInput] entity.
 Historically, this method allowed a body in the DELETE, but that behavior is
-link:https://www.gerritcodereview.com/releases/2.16.md[deprecated].
+link:https://www.gerritcodereview.com/releases/2.16.md[deprecated,role=external,window=_blank].
 In this case, use link:#set-project-description[PUT] instead.
 
 .Request
@@ -2925,6 +2926,374 @@
   HTTP/1.1 204 No Content
 ----
 
+[[label-endpoints]]
+== Label Endpoints
+
+[[list-labels]]
+=== List Labels
+--
+'GET /projects/link:#project-name[\{project-name\}]/labels/'
+--
+
+Lists the labels that are defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/labels/ HTTP/1.0
+----
+
+As result a list of link:#label-definition-info[LabelDefinitionInfo] entities
+is returned that describe the labels that are defined in this project
+(inherited labels are not returned unless the `inherited` parameter is set, see
+link:#list-with-inherited-labels[below]). The returned labels are sorted by
+label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "project": "All-Projects",
+      "function": "MaxWithBlock",
+      "values": {
+        " 0": "No score",
+        "-1": "I would prefer this is not merged as is",
+        "-2": "This shall not be merged",
+        "+1": "Looks good to me, but someone else must approve",
+        "+2": "Looks good to me, approved"
+      },
+      "default_value": 0,
+      "can_override": true,
+      "copy_min_score": true,
+      "copy_all_scores_if_no_change": true,
+      "copy_all_scores_on_trivial_rebase": true,
+      "allow_post_submit": true
+    }
+  ]
+----
+
+[[list-with-inherited-labels]]
+To include inherited labels from all parent projects the parameter `inherited`
+can be set.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project and all its parent projects.
+
+.Request
+----
+  GET /projects/My-Project/labels/?inherited HTTP/1.0
+----
+
+As result a list of link:#label-definition-info[LabelDefinitionInfo] entities
+is returned that describe the labels that are defined in this project and in
+all its parent projects. The returned labels are sorted by parent projects
+in-order from `All-Projects` through the project hierarchy to this project.
+Labels that belong to the same project are sorted by label name.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "project": "All-Projects",
+      "function": "MaxWithBlock",
+      "values": {
+        " 0": "No score",
+        "-1": "I would prefer this is not merged as is",
+        "-2": "This shall not be merged",
+        "+1": "Looks good to me, but someone else must approve",
+        "+2": "Looks good to me, approved"
+      },
+      "default_value": 0,
+      "can_override": true,
+      "copy_min_score": true,
+      "copy_all_scores_if_no_change": true,
+      "copy_all_scores_on_trivial_rebase": true,
+      "allow_post_submit": true
+    },
+    {
+      "name": "Foo-Review",
+      "project": "My-Project",
+      "function": "MaxWithBlock",
+      "values": {
+        " 0": "No score",
+        "-1": "I would prefer this is not merged as is",
+        "-2": "This shall not be merged",
+        "+1": "Looks good to me, but someone else must approve",
+        "+2": "Looks good to me, approved"
+      },
+      "default_value": 0,
+      "can_override": true,
+      "copy_any_score": true,
+      "allow_post_submit": true
+    }
+  ]
+----
+
+[[get-label]]
+=== Get Label
+--
+'GET /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Retrieves the definition of a label that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/labels/Code-Review HTTP/1.0
+----
+
+As response a link:#label-definition-info[LabelDefinitionInfo] entity is
+returned that describes the label.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "project": "All-Projects",
+    "function": "MaxWithBlock",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    },
+    "default_value": 0,
+    "can_override": true,
+    "copy_min_score": true,
+    "copy_all_scores_if_no_change": true,
+    "copy_all_scores_on_trivial_rebase": true,
+    "allow_post_submit": true
+  }
+----
+
+[[create-label]]
+=== Create Label
+--
+'PUT /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Creates a new label definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a label with this name is already defined in this project, this label
+definition is updated (see link:#set-label[Set Label]).
+
+.Request
+----
+  PUT /projects/My-Project/labels/Foo HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Create Foo Label",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    }
+  }
+----
+
+As response a link:#label-definition-info[LabelDefinitionInfo] entity is
+returned that describes the created label.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Foo",
+    "project_name": "My-Project",
+    "function": "MaxWithBlock",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    },
+    "default_value": 0,
+    "can_override": true,
+    "copy_all_scores_if_no_change": true,
+    "allow_post_submit": true
+  }
+----
+
+[[set-label]]
+=== Set Label
+--
+'PUT /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Updates the definition of a label that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+Properties which are not set in the input entity are not modified.
+
+.Request
+----
+  PUT /projects/All-Projects/labels/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Ignore self approvals for Code-Review label",
+    "ignore_self_approval": true
+  }
+----
+
+As response a link:#label-definition-info[LabelDefinitionInfo] entity is
+returned that describes the updated label.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "project": "All-Projects",
+    "function": "MaxWithBlock",
+    "values": {
+      " 0": "No score",
+      "-1": "I would prefer this is not merged as is",
+      "-2": "This shall not be merged",
+      "+1": "Looks good to me, but someone else must approve",
+      "+2": "Looks good to me, approved"
+    },
+    "default_value": 0,
+    "can_override": true,
+    "copy_min_score": true,
+    "copy_all_scores_if_no_change": true,
+    "copy_all_scores_on_trivial_rebase": true,
+    "allow_post_submit": true,
+    "ignore_self_approval": true
+  }
+----
+
+[[delete-label]]
+=== Delete Label
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/labels/link:#label-name[\{label-name\}]'
+--
+
+Deletes the definition of a label that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The request body does not need to include a link:#delete-label-input[
+DeleteLabelInput] entity if no commit message is specified.
+
+.Request
+----
+  DELETE /projects/My-Project/labels/Foo-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Delete Foo-Review label",
+  }
+----
+
+If a label was deleted the response is "`204 No Content`".
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+[[batch-update-labels]]
+=== Batch Update Labels
+--
+'POST /projects/link:#project-name[\{project-name\}]/labels/'
+--
+
+Creates/updates/deletes multiple label definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-label-input[BatchLabelInput] entity.
+
+The updates are processed in the following order:
+
+1. label deletions
+2. label creations
+3. label updates
+
+.Request
+----
+  POST /projects/My-Project/labels/ HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update Labels",
+    "delete": [
+      "Old-Review",
+      "Unused-Review"
+    ],
+    "create": [
+      {
+        "name": "Foo-Review",
+        "values": {
+          " 0": "No score",
+          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be merged",
+          "+1": "Looks good to me, but someone else must approve",
+          "+2": "Looks good to me, approved"
+      }
+    ],
+    "update:" {
+      "Bar-Review": {
+        "function": "MaxWithBlock"
+      },
+      "Baz-Review": {
+        "copy_min_score": true
+      }
+    }
+  }
+----
+
+If the label updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
 
 [[ids]]
 == IDs
@@ -2949,6 +3318,10 @@
 A special dashboard ID is `default` which represents the default
 dashboard of a project.
 
+[[label-name]]
+=== \{label-name\}
+The name of a review label.
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3122,6 +3495,25 @@
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
+
+[[commentlink-input]]
+=== CommentLinkInput
+The `CommentLinkInput` entity describes the input for a
+link:config-gerrit.html#commentlink[commentlink].
+
+|==================================================
+[options="header",cols="1,^2,4"]
+|==================================================
+|Field Name |        |Description
+|`match`    |        |A JavaScript regular expression to match
+positions to be replaced with a hyperlink, as documented in
+link:config-gerrit.html#commentlink.name.match[commentlink.name.match].
+|`link`     |        |The URL to direct the user to whenever the
+regular expression is matched, as documented in
+link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`enabled`  |optional|Whether the commentlink is enabled, as documented
+in link:config-gerrit.html#commentlink.name.enabled[
+commentlink.name.enabled]. If not set the commentlink is enabled.
 |==================================================
 
 [[config-info]]
@@ -3272,6 +3664,11 @@
 Whether empty commits should be rejected when a change is merged.
 Can be `TRUE`, `FALSE` or `INHERIT`. +
 If not set, this setting is not updated.
+|commentlinks                              |optional|
+Map of commentlink names to link:#commentlink-input[CommentLinkInput]
+entities to add or update on the project. If the given commentlink
+already exists, it will be updated with the given values, otherwise
+it will be created. If the value is null, that entry is deleted.
 |======================================================
 
 [[config-parameter-info]]
@@ -3309,8 +3706,6 @@
 |`inherited_value` |optional|
 The inherited value of the configuration parameter, only set if
 `inheritable` is true.
-|`permitted_values` |optional|
-The list of permitted values, only set if the `type` is `LIST`.
 |===============================
 
 [[dashboard-info]]
@@ -3377,6 +3772,19 @@
 Tokens such as `${project}` are not resolved.
 |===========================
 
+[[delete-label-input]]
+=== DeleteLabelInput
+The `DeleteLabelInput` entity contains information for deleting a label
+definition in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the deletion of the label in the
+`project.config` file to the `refs/meta/config` branch.
+|=============================
+
 [[delete-branches-input]]
 === DeleteBranchesInput
 The `DeleteBranchesInput` entity contains information about branches that should
@@ -3459,6 +3867,128 @@
 Not set if there is no parent.
 |================================
 
+[[label-definition-info]]
+=== LabelDefinitionInfo
+The `LabelDefinitionInfo` entity describes a link:config-labels.html[
+review label].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`name`          ||
+The link:config-labels.html#label_name[name] of the label.
+|`project_name`  ||
+The name of the project in which this label is defined.
+|`function`      ||
+The link:config-labels.html#label_function[function] of the label (can be
+`MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
+|`values`        ||
+The link:config-labels.html#label_value[values] of the label as a map of label
+value to value description. The label values are formatted strings, e.g. "+1"
+instead of "1", " 0" instead of "0".
+|`default_value` ||
+The link:config-labels.html#label_defaultValue[default value] of the label (as
+integer).
+|`branches`      |optional|
+A list of link:config-labels.html#label_branch[branches] for which the label
+applies. A branch can be a ref, a ref pattern or a regular expression. If not
+set, the label applies for all branches.
+|`can_override`  |`false` if not set|
+Whether this label can be link:config-labels.html#label_canOverride[overridden]
+by child projects.
+|`copy_any_score`|`false` if not set|
+Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
+label.
+|`copy_min_score`|`false` if not set|
+Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
+label.
+|`copy_max_score`|`false` if not set|
+Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
+label.
+|`copy_all_scores_if_no_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfNoChange[
+copyAllScoresIfNoChange] is set on the label.
+|`copy_all_scores_if_no_code_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
+copyAllScoresIfNoCodeChange] is set on the label.
+|`copy_all_scores_on_trivial_rebase`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
+copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
+copyAllScoresOnMergeFirstParentUpdate] is set on the label.
+|`allow_post_submit`|`false` if not set|
+Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
+on the label.
+|`ignore_self_approval`|`false` if not set|
+Whether link:config-labels.html#label_ignoreSelfApproval[ignoreSelfApproval] is
+set on the label.
+|=============================
+
+[[label-definition-input]]
+=== LabelDefinitionInput
+The `LabelDefinitionInput` entity describes a link:config-labels.html[
+review label].
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the change of the label in the
+`project.config` file to the `refs/meta/config` branch.+
+Must not be set if this `LabelDefinitionInput` entity is contained in a
+link:#batch-label-input[BatchLabelInput] entity.
+|`name`          |optional|
+The new link:config-labels.html#label_name[name] of the label.+
+For label creation the name is required if this `LabelDefinitionInput` entity
+is contained in a link:#batch-label-input[BatchLabelInput]
+entity.
+|`function`      |optional|
+The new link:config-labels.html#label_function[function] of the label (can be
+`MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
+|`values`        |optional|
+The new link:config-labels.html#label_value[values] of the label as a map of
+label value to value description. The label values are formatted strings, e.g.
+"+1" instead of "1", " 0" instead of "0".
+|`default_value` |optional|
+The new link:config-labels.html#label_defaultValue[default value] of the label
+(as integer).
+|`branches`      |optional|
+The new branches for which the label applies as a list of
+link:config-labels.html#label_branch[branches]. A branch can be a ref, a ref
+pattern or a regular expression. If not set, the label applies for all
+branches.
+|`can_override`  |optional|
+Whether this label can be link:config-labels.html#label_canOverride[overridden]
+by child projects.
+|`copy_any_score`|optional|
+Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
+label.
+|`copy_min_score`|optional|
+Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
+label.
+|`copy_max_score`|optional|
+Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
+label.
+|`copy_all_scores_if_no_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfNoChange[
+copyAllScoresIfNoChange] is set on the label.
+|`copy_all_scores_if_no_code_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
+copyAllScoresIfNoCodeChange] is set on the label.
+|`copy_all_scores_on_trivial_rebase`|optional|
+Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
+copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_on_merge_first_parent_update`|optional|
+Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
+copyAllScoresOnMergeFirstParentUpdate] is set on the label.
+|`allow_post_submit`|optional|
+Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
+on the label.
+|`ignore_self_approval`|optional|
+Whether link:config-labels.html#label_ignoreSelfApproval[ignoreSelfApproval] is
+set on the label.
+|=============================
 
 [[label-type-info]]
 === LabelTypeInfo
@@ -3495,6 +4025,27 @@
 Not set if not inherited or overridden.
 |===============================
 
+[[batch-label-input]]
+=== BatchLabelInput
+The `BatchLabelInput` entity contains information for batch updating label
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name      ||Description
+|`commit_message`|optional|
+Message that should be used to commit the label updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete`        |optional|
+List of labels that should be deleted.
+|`create`        |optional|
+List of link:#label-definition-input[LabelDefinitionInput] entities that
+describe labels that should be created.
+|`update`        |optional|
+Map of label names to link:#label-definition-input[LabelDefinitionInput]
+entities that describe the updates that should be done for the labels.
+|=============================
+
 [[project-access-input]]
 === ProjectAccessInput
 The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index a8ab353..eabcaa9 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - REST API
 
 Gerrit Code Review comes with a REST like API available over HTTP.
@@ -128,7 +129,7 @@
 === Response Codes
 The Gerrit REST endpoints use HTTP status codes as described
 in the link:http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html[
-HTTP specification].
+HTTP specification,role=external,window=_blank].
 
 In most cases, the response body of an error response will be a
 plaintext, human-readable error message.
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index 1f5e195..aa3c0a8 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Creating and Editing Changes in the Gerrit Web Interface
 
 == Overview
@@ -16,7 +17,7 @@
 
 To create a change in the Gerrit web interface:
 
-. From the link:http://gerrit-review.googlesource.com[Gerrit Code Review]
+. From the link:http://gerrit-review.googlesource.com[Gerrit Code Review,role=external,window=_blank]
   dashboard, select Browse > Repositories.
 
 . Under Repository Name, click the name of the repository you want to work
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 3c922ed..5346b2e 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -125,6 +125,7 @@
 should be sent to the emails named in this section. Within a Git-style
 configuration file double quotes around complex operator values may
 need to be escaped, e.g. `filter = branch:\"^(maint|stable)-.*\"`.
+Single quotes are illegal and must be omitted.
 
 When sending email to a bare email address in a notify block, Gerrit
 Code Review ignores read access controls and assumes the administrator
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
index b26f4c1..e684b85 100644
--- a/Documentation/user-request-tracing.txt
+++ b/Documentation/user-request-tracing.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Request Tracing
 
 [[on-demand]]
@@ -75,7 +76,7 @@
 [[auto-retry-succeeded]]
 If an auto-retry succeeds you may consider filing this as
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=GoogleSource+Issue[
-Gerrit issue] so that the Gerrit developers can fix this and treat this
+Gerrit issue,role=external,window=_blank] so that the Gerrit developers can fix this and treat this
 exception as recoverable.
 
 The trace IDs for auto-retries are generated and start with
@@ -86,8 +87,7 @@
 `AutoRetry`. For each auto-retry that happened this should match 1 or 2
 log entries:
 
-* one `ERROR` log entry with the exception that triggered the
-  auto-retry
+* one `FINE` log entry with the exception that triggered the auto-retry
 * one `FINE` log entry with the exception that happened on auto-retry
   (if this log entry is not present the operation succeeded on
   auto-retry)
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index de17c00..06c5ab7 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Review UI
 
 Reviewing changes is an important task and the Gerrit Web UI provides
@@ -432,7 +433,7 @@
 The available download commands depend on the installed Gerrit plugins.
 The most popular plugin for download commands, the
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
-download-commands] plugin, provides commands to checkout, pull and
+download-commands,role=external,window=_blank] plugin, provides commands to checkout, pull and
 cherry-pick a patch set.
 
 Each command has a copy-to-clipboard icon that allows the command to be
@@ -943,7 +944,7 @@
 contain a match and one navigates to it.
 
 For additional possibilities to search please check the
-link:http://www.vim.org/docs.php[Vim documentation]. There are other
+link:http://www.vim.org/docs.php[Vim documentation,role=external,window=_blank]. There are other
 useful ways to search, e.g. while the cursor is on a word, pressing `*`
 or `#` searches for the next or previous occurrence of the word.
 
@@ -962,7 +963,7 @@
 - `gg` / `G` moves to cursor to the start / end of the file
 - `Ctrl-D` / `Ctrl-U` scrolls downwards / upwards
 
-Please check the link:http://www.vim.org/docs.php[Vim documentation]
+Please check the link:http://www.vim.org/docs.php[Vim documentation,role=external,window=_blank]
 for further information.
 
 [[diff-preferences]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 55a9ab7..359c32a 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Searching Changes
 
 == Default Searches
@@ -140,6 +141,11 @@
 +
 Changes that revert the change specified by the numeric 'ID'.
 
+[[submissionid]]
+submissionid:'ID'::
++
+Changes that have the specified submission 'ID'.
+
 [[reviewerin]]
 reviewerin:'GROUP'::
 +
@@ -156,7 +162,7 @@
 Changes occurring in 'PROJECT'. If 'PROJECT' starts with `^` it
 matches project names by regular expression.  The
 link:http://www.brics.dk/automaton/[dk.brics.automaton
-library] is used for evaluation of such patterns.
+library,role=external,window=_blank] is used for evaluation of such patterns.
 
 [[projects]]
 projects:'PREFIX'::
@@ -175,7 +181,7 @@
 Changes occurring in 'REPOSITORY'. If 'REPOSITORY' starts with `^` it
 matches repository names by regular expression.  The
 link:http://www.brics.dk/automaton/[dk.brics.automaton
-library] is used for evaluation of such patterns.
+library,role=external,window=_blank] is used for evaluation of such patterns.
 
 [[repositories]]
 repositories:'PREFIX', repos:'PREFIX'::
@@ -198,7 +204,7 @@
 If 'BRANCH' starts with `^` it matches branch names by regular
 expression patterns.  The
 link:http://www.brics.dk/automaton/[dk.brics.automaton
-library] is used for evaluation of such patterns.
+library,role=external,window=_blank] is used for evaluation of such patterns.
 
 [[intopic]]
 intopic:'TOPIC'::
@@ -208,7 +214,7 @@
 If 'TOPIC' starts with `^` it matches topic names by regular
 expression patterns.  The
 link:http://www.brics.dk/automaton/[dk.brics.automaton
-library] is used for evaluation of such patterns.
+library,role=external,window=_blank] is used for evaluation of such patterns.
 
 [[topic]]
 topic:'TOPIC'::
@@ -223,6 +229,16 @@
 Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG'.
 The match is case-insensitive.
 
+[[cherrypickof]]
+cherrypickof:'CHANGE[,PATCHSET]'::
++
+Changes which were created using the 'cherry-pick' functionality and
+whose source change number matches 'CHANGE' and source patchset number
+matches 'PATCHSET'. Note that 'PATCHSET' is optional. For example, a
+`cherrypickof:12345` matches all changes which were cherry-picked from
+change 12345 and `cherrypickof:12345,2` matches all changes which were
+cherry-picked from the 2nd patchset of change 12345.
+
 [[ref]]
 ref:'REF'::
 +
@@ -233,7 +249,7 @@
 If 'REF' starts with `^` it matches reference names by regular
 expression patterns.  The
 link:http://www.brics.dk/automaton/[dk.brics.automaton
-library] is used for evaluation of such patterns.
+library,role=external,window=_blank] is used for evaluation of such patterns.
 
 [[tr,bug]]
 tr:'ID', bug:'ID'::
@@ -268,7 +284,7 @@
 Matches any change touching file at 'PATH'. By default exact path
 matching is used, but regular expressions can be enabled by starting
 with `^`.  For example, to match all XML files use `file:^.*\.xml$`.
-The link:http://www.brics.dk/automaton/[dk.brics.automaton library]
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
 is used for the evaluation of such patterns.
 +
 The `^` required at the beginning of the regular expression not only
@@ -327,7 +343,7 @@
 +
 If 'DIR' starts with `^` it matches directories and directory segments by
 regular expression. The link:http://www.brics.dk/automaton/[dk.brics.automaton
-library] is used for evaluation of such patterns.
+library,role=external,window=_blank] is used for evaluation of such patterns.
 
 [[footer-operator]]
 footer:'FOOTER'::
diff --git a/Documentation/user-signedoffby.txt b/Documentation/user-signedoffby.txt
index 507f4f2..2b000f6 100644
--- a/Documentation/user-signedoffby.txt
+++ b/Documentation/user-signedoffby.txt
@@ -1,7 +1,8 @@
+:linkattrs:
 = Gerrit Code Review - Signed-off-by Lines
 
 [NOTE]
-This document was literally taken from link:http://git.kernel.org/?p=linux/kernel/git/torvalds/linux-2.6.git;a=blob;f=Documentation/SubmittingPatches;hb=4e8a2372f9255a1464ef488ed925455f53fbdaa1[linux-2.6 Documentation/SubmittingPatches]
+This document was literally taken from link:http://git.kernel.org/?p=linux/kernel/git/torvalds/linux-2.6.git;a=blob;f=Documentation/SubmittingPatches;hb=4e8a2372f9255a1464ef488ed925455f53fbdaa1[linux-2.6 Documentation/SubmittingPatches,role=external,window=_blank]
 and is covered by the GPLv2.
 
 [[Signed-off-by]]
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 6cf5587..86719d2 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -1,3 +1,4 @@
+:linkattrs:
 = Gerrit Code Review - Uploading Changes
 
 Gerrit supports three methods of uploading changes:
@@ -618,7 +619,7 @@
 
 repo is a multiple repository management tool, most commonly
 used by the Android Open Source Project.  For more details, see
-link:http://source.android.com/source/using-repo.html[using repo].
+link:http://source.android.com/source/using-repo.html[using repo,role=external,window=_blank].
 
 [[repo_create]]
 === Create Changes
diff --git a/WORKSPACE b/WORKSPACE
index f5cd71c..6e9fb38 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -613,36 +613,36 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "7.0"
+OW2_VERS = "7.2"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "d74d4ba0dee443f68fb2dcb7fcdb945a2cd89912",
+    sha1 = "fa637eb67eb7628c915d73762b681ae7ff0b9731",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "4b310d20d6f1c6b7197a75f1b5d69f169bc8ac1f",
+    sha1 = "b6e6abe057f23630113f4167c34bda7086691258",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "478006d07b7c561ae3a92ddc1829bca81ae0cdd1",
+    sha1 = "ca2954e8d92a05bacc28ff465b25c70e0f512497",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "29bc62dcb85573af6e62e5b2d735ef65966c4180",
+    sha1 = "3a23cc36edaf8fc5a89cb100182758ccb5991487",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "18d4d07010c24405129a6dbb0e92057f8779fb9d",
+    sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
 )
 
 AUTO_VALUE_VERSION = "1.7"
@@ -862,30 +862,30 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-TRUTH_VERS = "1.0"
+TRUTH_VERS = "1.0.1"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "998e5fb3fa31df716574b4c9e8d374855e800451",
+    sha1 = "361459309085bd9441cb97b62f160e8b353a93c0",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "d85fbc1daf0510821f552f2aa71d9605e97aa438",
+    sha1 = "ef07b2cc2201472381fdd3bcf773310e22bb9080",
 )
 
 maven_jar(
     name = "truth-liteproto-extension",
     artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "7a279c50a0f93da15533cef4993b45606cf67d72",
+    sha1 = "bd1f5ac8a5f66e60cd1738f7b95c97a582ffcef9",
 )
 
 maven_jar(
     name = "truth-proto-extension",
     artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "8c0c2ea61750f02d0d5ce9c653106b6a5dc82d12",
+    sha1 = "039aa2d7c9196b30d367eac7cb467ecaa726e23d",
 )
 
 maven_jar(
@@ -894,54 +894,48 @@
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.18.v20190429"
+JETTY_VERS = "9.4.24.v20191120"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "290f7a88f351950d51ebc9fb4a794752c62d7de5",
+    sha1 = "ca1803fde51b795c0a8346ca8bc6277d9d04d01d",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "01aceff3608ca1b223bfd275a497797cfe675ef4",
+    sha1 = "9fa640d36c088cf55843900043d28aef830ade4d",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "b76ef50e04635f11d4d43bc6ccb7c4482a8384f0",
+    sha1 = "7885cc3d5d7701a444acada7ab97f89846514875",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "f4c2654db1a55f0780acdfcee8bb98550f56ca70",
-)
-
-maven_jar(
-    name = "jetty-continuation",
-    artifact = "org.eclipse.jetty:jetty-continuation:" + JETTY_VERS,
-    sha1 = "3c421a3be5be5805e32b1a7f9c6046526524181d",
+    sha1 = "22be18a055850a6cf3b0efd56c789c3929c87e98",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "c2e73db2db5c369326b717da71b6587b3da11e0e",
+    sha1 = "d3f0b0fb016ef8d35ffb199d928ffbcbfa121c86",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "844af5efe58ab23fd0166a796efef123f4cb06b0",
+    sha1 = "dcb6d4d505ef74898e3a64a38c40195c01e97119",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "13e6148bfda7ae511f69ae7e5e3ea898bc9b0e33",
+    sha1 = "3095acb088f4ff9e3fd9aedf98db73e3c18ea849",
 )
 
 maven_jar(
diff --git a/contrib/mitm-ui/README.md b/contrib/mitm-ui/README.md
deleted file mode 100644
index 1ec8dd4..0000000
--- a/contrib/mitm-ui/README.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Scripts for PolyGerrit local development against prod using MitmProxy.
-
-## Installation (OSX)
-
-1. Install Docker from http://docker.com
-2. Start the proxy and create a new proxied browser instance
-   ```
-   cd ~/gerrit
-   ~/mitm-gerrit/mitm-serve-app-dev.sh
-   ```
-3. Make sure that the browser uses the proxy provided by the command line,
-   e.g. if you are a Googler check that the BeyondCorp extension uses the
-   "System/Alternative" proxy.
-4. Install MITM certificates
-   - Open http://mitm.it in the proxied browser window
-   - Follow the instructions to install MITM certs
-
-## Usage
-
-### Add or replace a single plugin containing static content
-
-To develop unminified plugin that loads multiple files, use this.
-
-1. Create a new proxied browser window and start mitmproxy via Docker:
-   ```
-   ~/mitm-gerrit/mitm-single-plugin.sh ./path/to/static/plugin.html
-   ```
-2. Open any *.googlesource.com domain in proxied window
-3. plugin.html and ./path/to/static/* will be served
-
-### Add or replace a minified plugin for *.googlesource.com
-
-This flow assumes no additional .html/.js are needed, i.e. the plugin is a single file.
-
-1. Create a new proxied browser window and start mitmproxy via Docker:
-   ```
-   ~/mitm-gerrit/mitm-plugins.sh ./path/to/plugin.html,./maybe/one/more.js
-   ```
-2. Open any *.googlesource.com domain in proxied window
-3. plugin.html and more.js are served
-
-### Force or replace default site theme for *.googlesource.com
-
-1. Create a new proxied browser window and start mitmproxy via Docker:
-   ```
-   ~/mitm-gerrit/mitm-theme.sh ./path/to/theme.html
-   ```
-2. Open any *.googlesource.com domain in proxied window
-3. Default site themes are enabled.
-4. Local `theme.html` content replaces `/static/gerrit-theme.html`
-5. `/static/*` URLs are served from local theme directory, i.e. `./path/to/`
-
-### Serve uncompiled PolyGerrit
-
-1. Create a new proxied browser window and start mitmproxy via Docker:
-   ```
-   cd ~/gerrit
-   ~/mitm-gerrit/mitm-serve-app-dev.sh
-   ```
-2. Open any *.googlesource.com domain in proxied window
-3. Instead of prod UI (gr-app.html, gr-app.js), local source files will be served
diff --git a/contrib/mitm-ui/add-header.py b/contrib/mitm-ui/add-header.py
deleted file mode 100644
index f9b2b12..0000000
--- a/contrib/mitm-ui/add-header.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# mitmdump -s add-header.py
-def response(flow):
-    if flow.request.host == 'gerrit-review.googlesource.com' and flow.request.path == "/c/92000?1":
-        #flow.response.headers['any'] = '<meta.rdf>; rel=meta'
-        flow.response.headers['Link'] = '</changes/98000/detail?O=11640c>;rel="preload";crossorigin;'
diff --git a/contrib/mitm-ui/dev-chrome.sh b/contrib/mitm-ui/dev-chrome.sh
deleted file mode 100755
index adcb296..0000000
--- a/contrib/mitm-ui/dev-chrome.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/sh
-
-if [[ "$OSTYPE" != "darwin"* ]]; then
-    echo Only works on OSX.
-    exit 1
-fi
-
-/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=${HOME}/devchrome --proxy-server="127.0.0.1:8888"
diff --git a/contrib/mitm-ui/force-version.py b/contrib/mitm-ui/force-version.py
deleted file mode 100644
index a69c885..0000000
--- a/contrib/mitm-ui/force-version.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# mitmdump -q -p 8888 -s "force-version.py --version $1"
-# Request URL is not changed, only the response context
-from mitmproxy import http
-import argparse
-import re
-
-class Server:
-    def __init__(self, version):
-        self.version = version
-
-    def request(self, flow: http.HTTPFlow) -> None:
-        if "gr-app." in flow.request.pretty_url:
-            flow.request.url = re.sub(
-                r"polygerrit_ui/([\d.]+)/elements",
-                "polygerrit_ui/" + self.version + "/elements",
-                flow.request.url)
-
-def start():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--version", type=str, help="Rapid release version, e.g. 432.0")
-    args = parser.parse_args()
-    return Server(args.version)
diff --git a/contrib/mitm-ui/mitm-docker.sh b/contrib/mitm-ui/mitm-docker.sh
deleted file mode 100755
index a1206f7..0000000
--- a/contrib/mitm-ui/mitm-docker.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/sh
-
-extra_volume='/tmp:/tmp'
-
-POSITIONAL=()
-while [[ $# -gt 0 ]]
-do
-key="$1"
-
-case $key in
-    -v|--volume)
-    extra_volume="$2"
-    shift # past argument
-    shift # past value
-    ;;
-    *)    # unknown option
-    POSITIONAL+=("$1") # save it in an array for later
-    shift # past argument
-    ;;
-esac
-done
-set -- "${POSITIONAL[@]}" # restore positional parameters
-
-if [[ -z "$1" ]]; then
-    echo This is a runner for higher-level scripts, e.g. mitm-serve-app-dev.sh
-    echo Alternatively, pass mitmproxy script from the same dir as a parameter, e.g. serve-app-dev.py
-    exit 1
-fi
-
-gerrit_dir=$(pwd)
-mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
-
-CMD="${mitm_dir}/$1"
-
-docker run --rm -it \
-       -v ~/.mitmproxy:/home/mitmproxy/.mitmproxy \
-       -v ${mitm_dir}:${mitm_dir} \
-       -v ${gerrit_dir}:${gerrit_dir} \
-       -v ${gerrit_dir}/bazel-out:${gerrit_dir}/bazel-out \
-       -v ${extra_volume} \
-       -p 8888:8888 \
-       mitmproxy/mitmproxy:2.0.2 \
-       mitmdump -q -p 8888 -s "${CMD}"
diff --git a/contrib/mitm-ui/mitm-plugins.sh b/contrib/mitm-ui/mitm-plugins.sh
deleted file mode 100755
index fc542bb..0000000
--- a/contrib/mitm-ui/mitm-plugins.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/sh
-
-if [[ -z "$1" ]]; then
-    echo This script injects plugins for *.googlesource.com.
-    echo Provide plugin paths, comma-separated, as a parameter.
-    echo This script assumes files do not have dependencies, i.e. minified.
-    exit 1
-fi
-
-realpath() {
-    [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
-}
-
-join () {
-  local IFS="$1"
-  shift
-  echo "$*"
-}
-
-plugins=$1
-plugin_paths=()
-for plugin in $(echo ${plugins} | sed "s/,/ /g")
-do
-    plugin_paths+=($(realpath ${plugin}))
-done
-
-absolute_plugin_paths=$(join , "${plugin_paths[@]}")
-
-mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
-
-${mitm_dir}/dev-chrome.sh &
-
-bazel build //polygerrit-ui/app:test_components &
-
-${mitm_dir}/mitm-docker.sh \
-           "serve-app-dev.py \
-           --plugins ${absolute_plugin_paths} \
-           --strip_assets \
-           --components $(pwd)/bazel-bin/polygerrit-ui/app/"
diff --git a/contrib/mitm-ui/mitm-serve-app-dev.sh b/contrib/mitm-ui/mitm-serve-app-dev.sh
deleted file mode 100755
index d4c72cc..0000000
--- a/contrib/mitm-ui/mitm-serve-app-dev.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/sh
-
-workspace="./WORKSPACE"
-if [[ ! -f ${workspace} ]] || [[ ! $(head -n 1 ${workspace}) == *"gerrit"* ]]; then
-    echo Please change to cloned Gerrit repo from https://gerrit.googlesource.com/gerrit/
-    exit 1
-fi
-
-mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
-
-bazel build //polygerrit-ui/app:test_components &
-
-${mitm_dir}/dev-chrome.sh &
-
-${mitm_dir}/mitm-docker.sh "serve-app-dev.py --app $(pwd)/polygerrit-ui/app/ --components $(pwd)/bazel-bin/polygerrit-ui/app/"
diff --git a/contrib/mitm-ui/mitm-single-plugin.sh b/contrib/mitm-ui/mitm-single-plugin.sh
deleted file mode 100755
index 8958229..0000000
--- a/contrib/mitm-ui/mitm-single-plugin.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/sh
-
-if [[ -z "$1" ]]; then
-    echo This script serves one plugin with the rest of static content.
-    echo Provide path to index plugin file, e.g. buildbucket.html for buildbucket plugin
-    exit 1
-fi
-
-realpath() {
-  OURPWD=$PWD
-  cd "$(dirname "$1")"
-  LINK=$(basename "$1")
-  while [ -L "$LINK" ]; do
-      LINK=$(readlink "$LINK")
-      cd "$(dirname "$LINK")"
-      LINK="$(basename "$1")"
-  done
-  REAL_DIR=`pwd -P`
-  RESULT=$REAL_DIR/$LINK
-  cd "$OURPWD"
-  echo "$RESULT"
-}
-
-plugin=$(realpath $1)
-plugin_root=$(dirname ${plugin})
-
-mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
-
-${mitm_dir}/dev-chrome.sh &
-
-bazel build //polygerrit-ui/app:test_components &
-
-${mitm_dir}/mitm-docker.sh -v ${plugin_root}:${plugin_root} \
-           "serve-app-dev.py \
-           --plugins ${plugin} \
-           --strip_assets \
-           --plugin_root ${plugin_root}  \
-           --components $(pwd)/bazel-bin/polygerrit-ui/app/"
diff --git a/contrib/mitm-ui/mitm-theme.sh b/contrib/mitm-ui/mitm-theme.sh
deleted file mode 100755
index 9290235..0000000
--- a/contrib/mitm-ui/mitm-theme.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/sh
-
-if [[ -z "$1" ]]; then
-    echo This script forces or replaces default site theme on *.googlesource.com
-    echo Provide path to the theme.html as a parameter.
-    exit 1
-fi
-
-realpath() {
-  OURPWD=$PWD
-  cd "$(dirname "$1")"
-  LINK=$(basename "$1")
-  while [ -L "$LINK" ]; do
-      LINK=$(readlink "$LINK")
-      cd "$(dirname "$LINK")"
-      LINK="$(basename "$1")"
-  done
-  REAL_DIR=`pwd -P`
-  RESULT=$REAL_DIR/$LINK
-  cd "$OURPWD"
-  echo "$RESULT"
-}
-
-theme=$(realpath "$1")
-theme_dir=$(dirname "${theme}")
-
-mitm_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
-
-"${mitm_dir}"/dev-chrome.sh &
-
-"${mitm_dir}"/mitm-docker.sh -v "${theme_dir}":"${theme_dir}" "serve-app-dev.py --strip_assets --theme \"${theme}\""
diff --git a/contrib/mitm-ui/serve-app-dev.py b/contrib/mitm-ui/serve-app-dev.py
deleted file mode 100644
index cdf7bfc..0000000
--- a/contrib/mitm-ui/serve-app-dev.py
+++ /dev/null
@@ -1,169 +0,0 @@
-# 1. install and setup mitmproxy v2.0.2: https://mitmproxy.readthedocs.io/en/v2.0.2/install.html
-#   (In case of python versions trouble, use https://www.anaconda.com/)
-# 2. mitmdump -q -s -p 8888 \
-#   "serve-app-dev.py --app /path/to/polygerrit-ui/app/"
-# 3. start Chrome with --proxy-server="127.0.0.1:8888" --user-data-dir=/tmp/devchrome
-# 4. open, say, gerrit-review.googlesource.com. Or chromium-review.googlesource.com. Any.
-# 5. uncompiled source files are served and you can log in, too.
-# 6. enjoy!
-#
-# P.S. For replacing plugins, use --plugins or --plugin_root
-#
-# --plugin takes comma-separated list of plugins to add or replace.
-#
-# Example: Adding a new plugin to the server response:
-# --plugins ~/gerrit-testsite/plugins/myplugin.html
-#
-# Example: Replace all matching plugins with local versions:
-# --plugins ~/gerrit-testsite/plugins/
-# Following files will be served if they exist for /plugins/tricium/static/tricium.html:
-#  ~/gerrit-testsite/plugins/tricium.html
-#  ~/gerrit-testsite/plugins/tricium/static/tricium.html
-#
-# --assets takes assets bundle.html, expecting rest of the assets files to be in the same folder
-#
-# Example:
-#  --assets ~/gerrit-testsite/assets/a3be19f.html
-#
-
-from mitmproxy import http
-from mitmproxy.script import concurrent
-import argparse
-import json
-import mimetypes
-import os.path
-import re
-import zipfile
-
-class Server:
-    def __init__(self, devpath, components, plugins, pluginroot, assets, strip_assets, theme):
-        if devpath:
-            print("Serving app from " + devpath)
-        if components:
-            print("Serving components from " + components)
-        if pluginroot:
-            print("Serving plugins from " + pluginroot)
-        if assets:
-            self.assets_root, self.assets_file = os.path.split(assets)
-            print("Assets: using " + self.assets_file + " from " + self.assets_root)
-        else:
-            self.assets_root = None
-        if plugins:
-            self.plugins = {path.split("/")[-1:][0]: path for path in map(expandpath, plugins.split(","))}
-            for filename, path in self.plugins.items():
-                print("Serving " + filename + " from " + path)
-        else:
-            self.plugins = {}
-        self.devpath = devpath
-        self.components = components
-        self.pluginroot = pluginroot
-        self.strip_assets = strip_assets
-        self.theme = theme
-
-    def readfile(self, path):
-        with open(path, 'rb') as contentfile:
-            return contentfile.read()
-
-@concurrent
-def response(flow: http.HTTPFlow) -> None:
-    if server.strip_assets:
-        assets_bundle = 'googlesource.com/polygerrit_assets'
-        assets_pos = flow.response.text.find(assets_bundle)
-        if assets_pos != -1:
-            t = flow.response.text
-            flow.response.text = t[:t.rfind('<', 0, assets_pos)] + t[t.find('>', assets_pos) + 1:]
-            return
-
-    if server.assets_root:
-        marker = 'webcomponents-lite.js"></script>'
-        pos = flow.response.text.find(marker)
-        if pos != -1:
-            pos += len(marker)
-            flow.response.text = ''.join([
-                flow.response.text[:pos],
-                '<link rel="import" href="/gerrit_assets/123.0/' + server.assets_file + '">',
-                flow.response.text[pos:]
-            ])
-
-        assets_prefix = "/gerrit_assets/123.0/"
-        if flow.request.path.startswith(assets_prefix):
-            assets_file = flow.request.path[len(assets_prefix):]
-            flow.response.content = server.readfile(server.assets_root + '/' + assets_file)
-            flow.response.status_code = 200
-            if assets_file.endswith('.js'):
-                flow.response.headers['Content-type'] = 'text/javascript'
-            return
-    m = re.match(".+polygerrit_ui/\d+\.\d+/(.+)", flow.request.path)
-    pluginmatch = re.match("^/plugins/(.+)", flow.request.path)
-    localfile = ""
-    content = ""
-    if flow.request.path == "/config/server/info":
-        config = json.loads(flow.response.content[5:].decode('utf8'))
-        if server.theme:
-            config['default_theme'] = '/static/gerrit-theme.html'
-        for filename, path in server.plugins.items():
-            pluginname = filename.split(".")[0]
-            payload = config["plugin"]["js_resource_paths" if filename.endswith(".js") else "html_resource_paths"]
-            if list(filter(lambda url: filename in url, payload)):
-                continue
-            payload.append("plugins/" + pluginname + "/static/" + filename)
-        flow.response.content = str.encode(")]}'\n" + json.dumps(config))
-    if m is not None:
-        filepath = m.groups()[0]
-        if (filepath.startswith("bower_components/")):
-            with zipfile.ZipFile(server.components + "test_components.zip") as bower_zip:
-                content = bower_zip.read(filepath)
-        localfile = server.devpath + filepath
-    elif pluginmatch is not None:
-        pluginfile = flow.request.path_components[-1]
-        if server.plugins and pluginfile in server.plugins:
-            if os.path.isfile(server.plugins[pluginfile]):
-                localfile = server.plugins[pluginfile]
-            else:
-                print("Can't find file " + server.plugins[pluginfile] + " for " + flow.request.path)
-        elif server.pluginroot:
-            pluginurl = pluginmatch.groups()[0]
-            if os.path.isfile(server.pluginroot + pluginfile):
-                localfile = server.pluginroot + pluginfile
-            elif os.path.isfile(server.pluginroot + pluginurl):
-                localfile = server.pluginroot + pluginurl
-
-    if server.theme:
-        if flow.request.path.endswith('/gerrit-theme.html'):
-            localfile = server.theme
-        else:
-            match = re.match("^/static(/[\w\.]+)$", flow.request.path)
-            if match is not None:
-                localfile = os.path.dirname(server.theme) + match.group(1)
-
-    if localfile and os.path.isfile(localfile):
-        if pluginmatch is not None:
-            print("Serving " + flow.request.path + " from " + localfile)
-        content = server.readfile(localfile)
-
-    if content:
-        flow.response.content = content
-        flow.response.status_code = 200
-        localtype = mimetypes.guess_type(localfile)
-        if localtype and localtype[0]:
-            flow.response.headers['Content-type'] = localtype[0]
-
-def expandpath(path):
-    return os.path.realpath(os.path.expanduser(path))
-
-parser = argparse.ArgumentParser()
-parser.add_argument("--app", type=str, default="", help="Path to /polygerrit-ui/app/")
-parser.add_argument("--components", type=str, default="", help="Path to test_components.zip")
-parser.add_argument("--plugins", type=str, default="", help="Comma-separated list of plugin files to add/replace")
-parser.add_argument("--plugin_root", type=str, default="", help="Path containing individual plugin files to replace")
-parser.add_argument("--assets", type=str, default="", help="Path containing assets file to import.")
-parser.add_argument("--strip_assets", action="store_true", help="Strip plugin bundles from the response.")
-parser.add_argument("--theme", default="", type=str, help="Path to the default site theme to be used.")
-args = parser.parse_args()
-server = Server(expandpath(args.app) + '/',
-                expandpath(args.components) + '/',
-                args.plugins,
-                expandpath(args.plugin_root) + '/',
-                args.assets and expandpath(args.assets),
-                args.strip_assets,
-                expandpath(args.theme))
diff --git a/contrib/mitm-ui/serve-app-locally.py b/contrib/mitm-ui/serve-app-locally.py
deleted file mode 100644
index 636c684..0000000
--- a/contrib/mitm-ui/serve-app-locally.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# bazel build polygerrit-ui/app:gr-app
-# mitmdump -s "serve-app-locally.py ~/gerrit/bazel-bin/polygerrit-ui/app"
-from mitmproxy import http
-import argparse
-import os
-import zipfile
-
-class Server:
-    def __init__(self, bundle):
-        self.bundle = bundle
-        self.bundlemtime = 0
-        self.files = {
-            'polygerrit_ui/elements/gr-app.js': '',
-            'polygerrit_ui/elements/gr-app.html': '',
-            'polygerrit_ui/styles/main.css': '',
-        }
-        self.read_files()
-
-    def read_files(self):
-        if not os.path.isfile(self.bundle):
-            print("bundle not found!")
-            return
-        mtime = os.stat(self.bundle).st_mtime
-        if mtime <= self.bundlemtime:
-            return
-        self.bundlemtime = mtime
-        with zipfile.ZipFile(self.bundle) as z:
-            for fname in self.files:
-                print('Reading new content for ' + fname)
-                with z.open(fname, 'r') as content_file:
-                    self.files[fname] = content_file.read()
-
-    def response(self, flow: http.HTTPFlow) -> None:
-        self.read_files()
-        for name in self.files:
-            if name.rsplit('/', 1)[1] in flow.request.pretty_url:
-                flow.response.content = self.files[name]
-
-def expandpath(path):
-    return os.path.expanduser(path)
-
-def start():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("bundle", type=str)
-    args = parser.parse_args()
-    return Server(expandpath(args.bundle))
diff --git a/e2e-tests/load-tests/Dockerfile b/e2e-tests/load-tests/Dockerfile
new file mode 100644
index 0000000..ceae672
--- /dev/null
+++ b/e2e-tests/load-tests/Dockerfile
@@ -0,0 +1,32 @@
+FROM denvazh/gatling:3.2.1
+
+ARG gatling_git_version=1.0.9
+RUN apk add --no-cache maven
+RUN mvn dependency:get \
+        -DgroupId=com.gerritforge \
+        -DartifactId=gatling-git_2.12 \
+        -Dversion=$gatling_git_version \
+        -Dtype=pom
+RUN mvn dependency:copy-dependencies \
+        -f /root/.m2/repository/com/gerritforge/gatling-git_2.12/$gatling_git_version/gatling-git_2.12-$gatling_git_version.pom \
+        -DoutputDirectory=/opt/gatling/lib/
+RUN mvn dependency:get \
+        -Dartifact=com.gerritforge:gatling-git_2.12:$gatling_git_version:jar \
+        -Ddest=/opt/gatling/lib/gatling-git.jar
+
+ARG gatling_home=/home/gatling
+RUN addgroup -g 1000 -S appgroup && \
+        adduser -u 1000 -S gatling -G appgroup -h $gatling_home
+RUN cp -R /opt/gatling/* $gatling_home && \
+        chown -R gatling:appgroup $gatling_home
+
+WORKDIR $gatling_home
+USER gatling
+
+COPY ./src/test/scala/com/google/gerrit/scenarios $gatling_home/user-files/simulations
+COPY ./src/test/resources/application.conf $gatling_home/conf
+COPY ./src/test/resources/data $gatling_home/user-files/resources/data
+
+ENV GATLING_HOME=$gatling_home
+
+ENTRYPOINT ["/home/gatling/bin/gatling.sh"]
diff --git a/e2e-tests/load-tests/README.md b/e2e-tests/load-tests/README.md
new file mode 100644
index 0000000..534fde5
--- /dev/null
+++ b/e2e-tests/load-tests/README.md
@@ -0,0 +1,11 @@
+# How to build the Docker image
+
+```$shell
+docker build . -t e2e-tests
+```
+
+# How to run a test
+
+```$shell
+docker run -it e2e-tests -s com.google.gerrit.scenarios.ReplayRecordsFromFeederScenario
+```
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 86614b3..ca105f6 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -193,7 +193,13 @@
 
 @RunWith(ConfigSuite.class)
 public abstract class AbstractDaemonTest {
+
+  /**
+   * Test methods without special annotations will use a common server for efficiency reasons. The
+   * server is torn down after the test class is done.
+   */
   private static GerritServer commonServer;
+
   private static Description firstTest;
 
   @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
@@ -678,6 +684,14 @@
     return result;
   }
 
+  protected PushOneCommit.Result createChange(TestRepository<InMemoryRepository> repo)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    return result;
+  }
+
   protected PushOneCommit.Result createMergeCommitChange(String ref) throws Exception {
     return createMergeCommitChange(ref, "foo");
   }
diff --git a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
new file mode 100644
index 0000000..88b97c7
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+
+/** Checks if an account is indexed the correct number of times. */
+public class AccountIndexedCounter implements AccountIndexedListener {
+  private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
+
+  @Override
+  public void onAccountIndexed(int id) {
+    countsByAccount.incrementAndGet(id);
+  }
+
+  public void clear() {
+    countsByAccount.clear();
+  }
+
+  public void assertReindexOf(TestAccount testAccount) {
+    assertReindexOf(testAccount, 1);
+  }
+
+  public void assertReindexOf(AccountInfo accountInfo) {
+    assertReindexOf(Account.id(accountInfo._accountId), 1);
+  }
+
+  public void assertReindexOf(TestAccount testAccount, long expectedCount) {
+    assertThat(countsByAccount.asMap()).containsExactly(testAccount.id().get(), expectedCount);
+    clear();
+  }
+
+  public void assertReindexOf(Account.Id accountId, long expectedCount) {
+    assertThat(countsByAccount.asMap()).containsEntry(accountId.get(), expectedCount);
+    countsByAccount.remove(accountId.get());
+  }
+
+  public void assertNoReindex() {
+    assertThat(countsByAccount.asMap()).isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index e719f83..89d1f3d 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -1,5 +1,4 @@
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
-load("//tools/bzl:java.bzl", "java_library2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 FUNCTION_SRCS = [
@@ -63,7 +62,9 @@
     "//java/com/google/gerrit/pgm/http/jetty",
     "//java/com/google/gerrit/pgm/util",
     "//java/com/google/gerrit/truth",
+    "//java/com/google/gerrit/acceptance/config",
     "//java/com/google/gerrit/acceptance/testsuite/project",
+    "//java/com/google/gerrit/server/fixes/testing",
     "//java/com/google/gerrit/server/group/testing",
     "//java/com/google/gerrit/server/project/testing:project-test-util",
     "//java/com/google/gerrit/testing:gerrit-test-util",
@@ -107,28 +108,30 @@
     runtime_deps = DEPLOY_ENV + PGM_DEPLOY_ENV,
 )
 
-java_library2(
+exported_deps = [
+    ":function",
+    "//lib:jgit-junit",
+    "//lib:jimfs",
+    "//lib:servlet-api",
+    "//lib/httpcomponents:fluent-hc",
+    "//lib/httpcomponents:httpclient",
+    "//lib/httpcomponents:httpcore",
+    "//lib/mockito",
+    "//lib/truth",
+    "//lib/truth:truth-java8-extension",
+    "//lib/greenmail",
+] + TEST_DEPS
+
+java_library(
     name = "framework-lib",
     testonly = True,
     srcs = glob(
         ["**/*.java"],
         exclude = FUNCTION_SRCS,
     ),
-    exported_deps = [
-        ":function",
-        "//lib:jgit-junit",
-        "//lib:jimfs",
-        "//lib:servlet-api",
-        "//lib/httpcomponents:fluent-hc",
-        "//lib/httpcomponents:httpclient",
-        "//lib/httpcomponents:httpcore",
-        "//lib/mockito",
-        "//lib/truth",
-        "//lib/truth:truth-java8-extension",
-        "//lib/greenmail",
-    ] + TEST_DEPS,
     visibility = ["//visibility:public"],
-    deps = DEPLOY_ENV,
+    exports = exported_deps,
+    deps = DEPLOY_ENV + exported_deps,
 )
 
 java_library(
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 678bc31..95afad7 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -25,6 +25,11 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfigs;
+import com.google.gerrit.acceptance.config.GlobalPluginConfig;
+import com.google.gerrit.acceptance.config.GlobalPluginConfigs;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -264,7 +269,7 @@
 
   private static final ImmutableMap<String, Level> LOG_LEVELS =
       ImmutableMap.<String, Level>builder()
-          .put("com.google.gerrit", Level.DEBUG)
+          .put("com.google.gerrit", getGerritLogLevel())
 
           // Silence non-critical messages from MINA SSHD.
           .put("org.apache.mina", Level.WARN)
@@ -302,6 +307,14 @@
           .put("org.eclipse.jgit.util.FS", Level.WARN)
           .build();
 
+  private static Level getGerritLogLevel() {
+    String value = Strings.nullToEmpty(System.getenv("GERRIT_LOG_LEVEL"));
+    if (value.isEmpty()) {
+      value = Strings.nullToEmpty(System.getProperty("gerrit.logLevel"));
+    }
+    return Level.toLevel(value, Level.INFO);
+  }
+
   private static boolean forceLocalDisk() {
     String value = Strings.nullToEmpty(System.getenv("GERRIT_FORCE_LOCAL_DISK"));
     if (value.isEmpty()) {
@@ -558,8 +571,8 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
-    if (cfg.getString("index", null, "reindexAfterRefUpdate") == null) {
-      cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    if (cfg.getString("index", null, "mergeabilityComputationBehavior") == null) {
+      cfg.setString("index", null, "mergeabilityComputationBehavior", "NEVER");
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/UseLocalDisk.java b/java/com/google/gerrit/acceptance/UseLocalDisk.java
index e177bb4..192caa0 100644
--- a/java/com/google/gerrit/acceptance/UseLocalDisk.java
+++ b/java/com/google/gerrit/acceptance/UseLocalDisk.java
@@ -21,6 +21,15 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
+/**
+ * Annotation to mark tests that require a local disk for the execution.
+ *
+ * <p>Tests that do not have this annotation are executed in memory.
+ *
+ * <p>Using this annotation makes the execution of the test more expensive/slower. This is why it
+ * should only be used if the test requires a local disk (e.g. if the test triggers the Git garbage
+ * collection functionality which only works with a local disk).
+ */
 @Target({TYPE, METHOD})
 @Retention(RUNTIME)
 public @interface UseLocalDisk {}
diff --git a/java/com/google/gerrit/acceptance/UseSsh.java b/java/com/google/gerrit/acceptance/UseSsh.java
index 5509140..12a9977 100644
--- a/java/com/google/gerrit/acceptance/UseSsh.java
+++ b/java/com/google/gerrit/acceptance/UseSsh.java
@@ -21,6 +21,14 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 
+/**
+ * Annotation to mark SSH tests.
+ *
+ * <p>When running tests the SSH functionality is disabled unless the {@link UseSsh} annotation is
+ * used.
+ *
+ * <p>SSH tests can be skipped when executing tests (see {@link com.google.gerrit.testing.SshMode}).
+ */
 @Target({TYPE, METHOD})
 @Retention(RUNTIME)
 public @interface UseSsh {}
diff --git a/java/com/google/gerrit/acceptance/config/BUILD b/java/com/google/gerrit/acceptance/config/BUILD
new file mode 100644
index 0000000..a8ccc1f
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/config/BUILD
@@ -0,0 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_testonly = 1)
+
+java_library(
+    name = "config",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+    ],
+)
diff --git a/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
similarity index 89%
rename from java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
rename to java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
index 0a1d765..24a2117 100644
--- a/java/com/google/gerrit/acceptance/ConfigAnnotationParser.java
+++ b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance;
+package com.google.gerrit.acceptance.config;
 
 import com.google.auto.value.AutoAnnotation;
 import com.google.common.base.Splitter;
@@ -24,10 +24,10 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
-class ConfigAnnotationParser {
+public class ConfigAnnotationParser {
   private static Splitter splitter = Splitter.on(".").trimResults();
 
-  static Config parse(Config base, GerritConfigs annotation) {
+  public static Config parse(Config base, GerritConfigs annotation) {
     if (annotation == null) {
       return null;
     }
@@ -39,33 +39,13 @@
     return cfg;
   }
 
-  static Config parse(Config base, GerritConfig annotation) {
+  public static Config parse(Config base, GerritConfig annotation) {
     Config cfg = new Config(base);
     parseAnnotation(cfg, annotation);
     return cfg;
   }
 
-  private static GerritConfig toGerritConfig(GlobalPluginConfig annotation) {
-    return newGerritConfig(annotation.name(), annotation.value(), annotation.values());
-  }
-
-  @AutoAnnotation
-  private static GerritConfig newGerritConfig(String name, String value, String[] values) {
-    return new AutoAnnotation_ConfigAnnotationParser_newGerritConfig(name, value, values);
-  }
-
-  static Map<String, Config> parse(GlobalPluginConfig annotation) {
-    if (annotation == null) {
-      return null;
-    }
-    Map<String, Config> result = new HashMap<>();
-    Config cfg = new Config();
-    parseAnnotation(cfg, toGerritConfig(annotation));
-    result.put(annotation.pluginName(), cfg);
-    return result;
-  }
-
-  static Map<String, Config> parse(GlobalPluginConfigs annotation) {
+  public static Map<String, Config> parse(GlobalPluginConfigs annotation) {
     if (annotation == null || annotation.value().length < 1) {
       return null;
     }
@@ -87,6 +67,26 @@
     return result;
   }
 
+  public static Map<String, Config> parse(GlobalPluginConfig annotation) {
+    if (annotation == null) {
+      return null;
+    }
+    Map<String, Config> result = new HashMap<>();
+    Config cfg = new Config();
+    parseAnnotation(cfg, toGerritConfig(annotation));
+    result.put(annotation.pluginName(), cfg);
+    return result;
+  }
+
+  private static GerritConfig toGerritConfig(GlobalPluginConfig annotation) {
+    return newGerritConfig(annotation.name(), annotation.value(), annotation.values());
+  }
+
+  @AutoAnnotation
+  private static GerritConfig newGerritConfig(String name, String value, String[] values) {
+    return new AutoAnnotation_ConfigAnnotationParser_newGerritConfig(name, value, values);
+  }
+
   private static void parseAnnotation(Config cfg, GerritConfig c) {
     ArrayList<String> l = Lists.newArrayList(splitter.split(c.name()));
     if (l.size() == 2) {
diff --git a/java/com/google/gerrit/acceptance/GerritConfig.java b/java/com/google/gerrit/acceptance/config/GerritConfig.java
similarity index 96%
rename from java/com/google/gerrit/acceptance/GerritConfig.java
rename to java/com/google/gerrit/acceptance/config/GerritConfig.java
index fe0c628..26be2d4 100644
--- a/java/com/google/gerrit/acceptance/GerritConfig.java
+++ b/java/com/google/gerrit/acceptance/config/GerritConfig.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance;
+package com.google.gerrit.acceptance.config;
 
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
diff --git a/java/com/google/gerrit/acceptance/GerritConfigs.java b/java/com/google/gerrit/acceptance/config/GerritConfigs.java
similarity index 94%
rename from java/com/google/gerrit/acceptance/GerritConfigs.java
rename to java/com/google/gerrit/acceptance/config/GerritConfigs.java
index e0f9d4a..0127ace 100644
--- a/java/com/google/gerrit/acceptance/GerritConfigs.java
+++ b/java/com/google/gerrit/acceptance/config/GerritConfigs.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance;
+package com.google.gerrit.acceptance.config;
 
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
diff --git a/java/com/google/gerrit/acceptance/GlobalPluginConfig.java b/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
similarity index 96%
rename from java/com/google/gerrit/acceptance/GlobalPluginConfig.java
rename to java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
index 43477ae..ae88e37 100644
--- a/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
+++ b/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance;
+package com.google.gerrit.acceptance.config;
 
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
diff --git a/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java b/java/com/google/gerrit/acceptance/config/GlobalPluginConfigs.java
similarity index 95%
rename from java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
rename to java/com/google/gerrit/acceptance/config/GlobalPluginConfigs.java
index dfcf955..e53e5ba 100644
--- a/java/com/google/gerrit/acceptance/GlobalPluginConfigs.java
+++ b/java/com/google/gerrit/acceptance/config/GlobalPluginConfigs.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance;
+package com.google.gerrit.acceptance.config;
 
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
diff --git a/java/com/google/gerrit/acceptance/rest/PluginCollection.java b/java/com/google/gerrit/acceptance/rest/PluginCollection.java
index c0daf93..dc40cde 100644
--- a/java/com/google/gerrit/acceptance/rest/PluginCollection.java
+++ b/java/com/google/gerrit/acceptance/rest/PluginCollection.java
@@ -45,7 +45,7 @@
   @Override
   public PluginResource parse(ConfigResource parent, IdString id)
       throws ResourceNotFoundException, Exception {
-    throw new ResourceNotFoundException();
+    throw new ResourceNotFoundException(id);
   }
 
   @Override
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index fd5c003..21d1232 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -80,7 +80,7 @@
   private InternalGroupCreation toInternalGroupCreation(TestGroupCreation groupCreation) {
     AccountGroup.Id groupId = AccountGroup.id(seq.nextGroupId());
     String groupName = groupCreation.name().orElse("group-with-id-" + groupId.get());
-    AccountGroup.UUID groupUuid = GroupUUID.make(groupName, serverIdent);
+    AccountGroup.UUID groupUuid = GroupUuid.make(groupName, serverIdent);
     AccountGroup.NameKey nameKey = AccountGroup.nameKey(groupName);
     return InternalGroupCreation.builder()
         .setId(groupId)
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index d69f0bb..55e0143 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -18,10 +18,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 public class CommentDetail {
   protected List<Comment> a;
@@ -29,8 +26,6 @@
 
   private transient PatchSet.Id idA;
   private transient PatchSet.Id idB;
-  private transient Map<Integer, List<Comment>> forA;
-  private transient Map<Integer, List<Comment>> forB;
 
   public CommentDetail(PatchSet.Id idA, PatchSet.Id idB) {
     this.a = new ArrayList<>();
@@ -67,88 +62,4 @@
   public boolean isEmpty() {
     return a.isEmpty() && b.isEmpty();
   }
-
-  public List<Comment> getForA(int lineNbr) {
-    if (forA == null) {
-      forA = index(a);
-    }
-    return get(forA, lineNbr);
-  }
-
-  public List<Comment> getForB(int lineNbr) {
-    if (forB == null) {
-      forB = index(b);
-    }
-    return get(forB, lineNbr);
-  }
-
-  private static List<Comment> get(Map<Integer, List<Comment>> m, int i) {
-    List<Comment> r = m.get(i);
-    return r != null ? orderComments(r) : Collections.emptyList();
-  }
-
-  /**
-   * Order the comments based on their parent_uuid parent. It is possible to do this by iterating
-   * over the list only once but it's probably overkill since the number of comments on a given line
-   * will be small most of the time.
-   *
-   * @param comments The list of comments for a given line.
-   * @return The comments sorted as they should appear in the UI
-   */
-  private static List<Comment> orderComments(List<Comment> comments) {
-    // Map of comments keyed by their parent. The values are lists of comments since it is
-    // possible for several comments to have the same parent (this can happen if two reviewers
-    // click Reply on the same comment at the same time). Such comments will be displayed under
-    // their correct parent in chronological order.
-    Map<String, List<Comment>> parentMap = new HashMap<>();
-
-    // It's possible to have more than one root comment if two reviewers create a comment on the
-    // same line at the same time
-    List<Comment> rootComments = new ArrayList<>();
-
-    // Store all the comments in parentMap, keyed by their parent
-    for (Comment c : comments) {
-      String parentUuid = c.parentUuid;
-      List<Comment> l = parentMap.get(parentUuid);
-      if (l == null) {
-        l = new ArrayList<>();
-        parentMap.put(parentUuid, l);
-      }
-      l.add(c);
-      if (parentUuid == null) {
-        rootComments.add(c);
-      }
-    }
-
-    // Add the comments in the list, starting with the head and then going through all the
-    // comments that have it as a parent, and so on
-    List<Comment> result = new ArrayList<>();
-    addChildren(parentMap, rootComments, result);
-
-    return result;
-  }
-
-  /** Add the comments to {@code outResult}, depth first */
-  private static void addChildren(
-      Map<String, List<Comment>> parentMap, List<Comment> children, List<Comment> outResult) {
-    if (children != null) {
-      for (Comment c : children) {
-        outResult.add(c);
-        addChildren(parentMap, parentMap.get(c.key.uuid), outResult);
-      }
-    }
-  }
-
-  private Map<Integer, List<Comment>> index(List<Comment> in) {
-    HashMap<Integer, List<Comment>> r = new HashMap<>();
-    for (Comment p : in) {
-      List<Comment> l = r.get(p.lineNbr);
-      if (l == null) {
-        l = new ArrayList<>();
-        r.put(p.lineNbr, l);
-      }
-      l.add(p);
-    }
-    return r;
-  }
 }
diff --git a/java/com/google/gerrit/common/data/GarbageCollectionResult.java b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
index 5ed0158..5e3601e 100644
--- a/java/com/google/gerrit/common/data/GarbageCollectionResult.java
+++ b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
@@ -18,6 +18,7 @@
 import java.util.ArrayList;
 import java.util.List;
 
+/** A list of errors occurred during GC. */
 public class GarbageCollectionResult {
   protected List<Error> errors;
 
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index fbe1deb..10a66cc 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -20,7 +20,12 @@
 import java.util.Collections;
 import java.util.List;
 
-/** Server wide capabilities. Represented as {@link Permission} objects. */
+/**
+ * Server wide capabilities. Represented as {@link Permission} objects.
+ *
+ * <p>Contrary to {@link Permission}, global capabilities do not need a resource to check
+ * permissions on.
+ */
 public class GlobalCapability {
   /** Ability to view code review metadata refs in repositories. */
   public static final String ACCESS_DATABASE = "accessDatabase";
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
index f9cd562..14b8310 100644
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ b/java/com/google/gerrit/common/data/LabelType.java
@@ -157,6 +157,10 @@
     return name;
   }
 
+  public void setName(String name) {
+    this.name = checkName(name);
+  }
+
   public boolean matches(PatchSetApproval psa) {
     return psa.labelId().get().equalsIgnoreCase(name);
   }
@@ -173,6 +177,7 @@
     return canOverride;
   }
 
+  @Nullable
   public List<String> getRefPatterns() {
     return refPatterns;
   }
@@ -198,7 +203,7 @@
   }
 
   public void setRefPatterns(List<String> refPatterns) {
-    if (refPatterns != null) {
+    if (refPatterns != null && !refPatterns.isEmpty()) {
       this.refPatterns =
           refPatterns.stream().collect(collectingAndThen(toList(), Collections::unmodifiableList));
     } else {
@@ -210,6 +215,10 @@
     return values;
   }
 
+  public void setValues(List<LabelValue> values) {
+    this.values = sortValues(values);
+  }
+
   public LabelValue getMin() {
     if (values.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index c177e35..d8ec6a9 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
@@ -37,30 +39,42 @@
     GITLINK
   }
 
-  private Change.Key changeId;
-  private ChangeType changeType;
-  private String oldName;
-  private String newName;
-  private FileMode oldMode;
-  private FileMode newMode;
-  private List<String> header;
-  private DiffPreferencesInfo diffPrefs;
-  private SparseFileContent a;
-  private SparseFileContent b;
-  private List<Edit> edits;
-  private Set<Edit> editsDueToRebase;
-  private DisplayMethod displayMethodA;
-  private DisplayMethod displayMethodB;
-  private transient String mimeTypeA;
-  private transient String mimeTypeB;
-  private CommentDetail comments;
-  private List<Patch> history;
-  private boolean hugeFile;
-  private boolean intralineFailure;
-  private boolean intralineTimeout;
-  private boolean binary;
-  private transient String commitIdA;
-  private transient String commitIdB;
+  public static class PatchScriptFileInfo {
+    public final String name;
+    public final FileMode mode;
+    public final SparseFileContent content;
+    public final DisplayMethod displayMethod;
+    public final String mimeType;
+    public final String commitId;
+
+    PatchScriptFileInfo(
+        String name,
+        FileMode mode,
+        SparseFileContent content,
+        DisplayMethod displayMethod,
+        String mimeType,
+        String commitId) {
+      this.name = name;
+      this.mode = mode;
+      this.content = content;
+      this.displayMethod = displayMethod;
+      this.mimeType = mimeType;
+      this.commitId = commitId;
+    }
+  }
+
+  private final Change.Key changeId;
+  private final ChangeType changeType;
+  private final ImmutableList<String> header;
+  private final DiffPreferencesInfo diffPrefs;
+  private final ImmutableList<Edit> edits;
+  private final ImmutableSet<Edit> editsDueToRebase;
+  private final ImmutableList<Patch> history;
+  private final boolean intralineFailure;
+  private final boolean intralineTimeout;
+  private final boolean binary;
+  private final PatchScriptFileInfo fileInfoA;
+  private final PatchScriptFileInfo fileInfoB;
 
   public PatchScript(
       Change.Key ck,
@@ -69,19 +83,17 @@
       String nn,
       FileMode om,
       FileMode nm,
-      List<String> h,
+      ImmutableList<String> h,
       DiffPreferencesInfo dp,
       SparseFileContent ca,
       SparseFileContent cb,
-      List<Edit> e,
-      Set<Edit> editsDueToRebase,
+      ImmutableList<Edit> e,
+      ImmutableSet<Edit> editsDueToRebase,
       DisplayMethod ma,
       DisplayMethod mb,
       String mta,
       String mtb,
-      CommentDetail cd,
-      List<Patch> hist,
-      boolean hf,
+      ImmutableList<Patch> hist,
       boolean idf,
       boolean idt,
       boolean bin,
@@ -89,52 +101,23 @@
       String cmb) {
     changeId = ck;
     changeType = ct;
-    oldName = on;
-    newName = nn;
-    oldMode = om;
-    newMode = nm;
     header = h;
     diffPrefs = dp;
-    a = ca;
-    b = cb;
     edits = e;
     this.editsDueToRebase = editsDueToRebase;
-    displayMethodA = ma;
-    displayMethodB = mb;
-    mimeTypeA = mta;
-    mimeTypeB = mtb;
-    comments = cd;
     history = hist;
-    hugeFile = hf;
     intralineFailure = idf;
     intralineTimeout = idt;
     binary = bin;
-    commitIdA = cma;
-    commitIdB = cmb;
-  }
 
-  protected PatchScript() {}
+    fileInfoA = new PatchScriptFileInfo(on, om, ca, ma, mta, cma);
+    fileInfoB = new PatchScriptFileInfo(nn, nm, cb, mb, mtb, cmb);
+  }
 
   public Change.Key getChangeId() {
     return changeId;
   }
 
-  public DisplayMethod getDisplayMethodA() {
-    return displayMethodA;
-  }
-
-  public DisplayMethod getDisplayMethodB() {
-    return displayMethodB;
-  }
-
-  public FileMode getFileModeA() {
-    return oldMode;
-  }
-
-  public FileMode getFileModeB() {
-    return newMode;
-  }
-
   public List<String> getPatchHeader() {
     return header;
   }
@@ -144,15 +127,11 @@
   }
 
   public String getOldName() {
-    return oldName;
+    return fileInfoA.name;
   }
 
   public String getNewName() {
-    return newName;
-  }
-
-  public CommentDetail getCommentDetail() {
-    return comments;
+    return fileInfoB.name;
   }
 
   public List<Patch> getHistory() {
@@ -163,14 +142,6 @@
     return diffPrefs;
   }
 
-  public void setDiffPrefs(DiffPreferencesInfo dp) {
-    diffPrefs = dp;
-  }
-
-  public boolean isHugeFile() {
-    return hugeFile;
-  }
-
   public boolean isIgnoreWhitespace() {
     return diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE;
   }
@@ -183,24 +154,12 @@
     return intralineTimeout;
   }
 
-  public boolean isExpandAllComments() {
-    return diffPrefs.expandAllComments;
-  }
-
   public SparseFileContent getA() {
-    return a;
+    return fileInfoA.content;
   }
 
   public SparseFileContent getB() {
-    return b;
-  }
-
-  public String getMimeTypeA() {
-    return mimeTypeA;
-  }
-
-  public String getMimeTypeB() {
-    return mimeTypeB;
+    return fileInfoB.content;
   }
 
   public List<Edit> getEdits() {
@@ -215,11 +174,11 @@
     return binary;
   }
 
-  public String getCommitIdA() {
-    return commitIdA;
+  public PatchScriptFileInfo getFileInfoA() {
+    return fileInfoA;
   }
 
-  public String getCommitIdB() {
-    return commitIdB;
+  public PatchScriptFileInfo getFileInfoB() {
+    return fileInfoB;
   }
 }
diff --git a/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
index d16da96..afb3bac 100644
--- a/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
@@ -65,7 +65,7 @@
     StringBuilder sb = new StringBuilder();
     sb.append(status);
     if (status == Status.RULE_ERROR && errorMessage != null) {
-      sb.append('(').append(errorMessage).append(")");
+      sb.append(" (").append(errorMessage).append(")");
     }
     if (type != null) {
       sb.append('[');
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 864839a..5f412ce 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.common.io.CharStreams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
@@ -68,7 +69,6 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import org.apache.commons.codec.binary.Base64;
 import org.apache.http.HttpEntity;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
@@ -88,7 +88,7 @@
   protected static final String SETTINGS = "settings";
 
   protected static byte[] decodeBase64(String base64String) {
-    return Base64.decodeBase64(base64String);
+    return BaseEncoding.base64().decode(base64String);
   }
 
   protected static <T> List<T> decodeProtos(
@@ -268,7 +268,7 @@
         } else if (type == FieldType.TIMESTAMP) {
           rawFields.put(element.getKey(), new Timestamp(inner.getAsLong()));
         } else if (type == FieldType.STORED_ONLY) {
-          rawFields.put(element.getKey(), Base64.decodeBase64(inner.getAsString()));
+          rawFields.put(element.getKey(), decodeBase64(inner.getAsString()));
         } else {
           throw FieldType.badFieldType(type);
         }
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
index edbd82c..8bab80b 100644
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ b/java/com/google/gerrit/elasticsearch/BUILD
@@ -19,7 +19,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
-        "//lib/commons:codec",
         "//lib/commons:lang",
         "//lib/elasticsearch-rest-client",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index a06f90f..c3e3264 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -74,7 +75,7 @@
   public void replace(AccountState as) {
     BulkRequest bulk =
         new IndexRequest(getId(as), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, as));
+            .add(new UpdateRequest<>(schema, as, ImmutableSet.of()));
 
     String uri = getURI(type, BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 37184cc..f659bca 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -48,6 +49,8 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -65,6 +68,7 @@
 import java.util.Optional;
 import java.util.Set;
 import org.apache.http.HttpStatus;
+import org.eclipse.jgit.lib.Config;
 import org.elasticsearch.client.Response;
 
 /** Secondary index implementation using Elasticsearch. */
@@ -91,6 +95,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final Schema<ChangeData> schema;
   private final FieldDef<ChangeData, ?> idField;
+  private final ImmutableSet<String> skipFields;
 
   @Inject
   ElasticChangeIndex(
@@ -98,6 +103,7 @@
       ChangeData.Factory changeDataFactory,
       SitePaths sitePaths,
       ElasticRestClientProvider clientBuilder,
+      @GerritServerConfig Config gerritConfig,
       @Assisted Schema<ChangeData> schema) {
     super(cfg, sitePaths, schema, clientBuilder, CHANGES);
     this.changeDataFactory = changeDataFactory;
@@ -105,6 +111,10 @@
     this.mapping = new ChangeMapping(schema, client.adapter());
     this.idField =
         this.schema.useLegacyNumericFields() ? ChangeField.LEGACY_ID : ChangeField.LEGACY_ID_STR;
+    this.skipFields =
+        MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex()
+            ? ImmutableSet.of()
+            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
   }
 
   @Override
@@ -123,7 +133,7 @@
     ElasticQueryAdapter adapter = client.adapter();
     BulkRequest bulk =
         new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
-            .add(new UpdateRequest<>(schema, cd));
+            .add(new UpdateRequest<>(schema, cd, skipFields));
     if (adapter.deleteToReplace()) {
       bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
     }
@@ -263,7 +273,7 @@
 
     // Mergeable.
     JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-    if (mergeableElement != null) {
+    if (mergeableElement != null && !skipFields.contains(ChangeField.MERGEABLE.getName())) {
       String mergeable = mergeableElement.getAsString();
       if ("1".equals(mergeable)) {
         cd.setMergeable(true);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index c215132..ce2025f 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -74,7 +75,7 @@
   public void replace(InternalGroup group) {
     BulkRequest bulk =
         new IndexRequest(getId(group), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, group));
+            .add(new UpdateRequest<>(schema, group, ImmutableSet.of()));
 
     String uri = getURI(type, BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
index 29f8507..b636706 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -74,7 +75,7 @@
   public void replace(ProjectData projectState) {
     BulkRequest bulk =
         new IndexRequest(projectState.getProject().getName(), indexName, type, client.adapter())
-            .add(new UpdateRequest<>(schema, projectState));
+            .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of()));
 
     String uri = getURI(type, BULK);
     Response response = postRequest(uri, bulk, getRefreshParam());
diff --git a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
index 2f0bd01..196b8d6 100644
--- a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
+++ b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.elasticsearch.builders.XContentBuilder;
@@ -27,17 +28,19 @@
 
   private final Schema<V> schema;
   private final V v;
+  private final ImmutableSet<String> skipFields;
 
-  public UpdateRequest(Schema<V> schema, V v) {
+  public UpdateRequest(Schema<V> schema, V v, ImmutableSet<String> skipFields) {
     this.schema = schema;
     this.v = v;
+    this.skipFields = skipFields;
   }
 
   @Override
   protected String getRequest() {
     try (XContentBuilder closeable = new XContentBuilder()) {
       XContentBuilder builder = closeable.startObject();
-      for (Values<V> values : schema.buildFields(v)) {
+      for (Values<V> values : schema.buildFields(v, skipFields)) {
         String name = values.getField().getName();
         if (values.getField().isRepeatable()) {
           builder.field(name, Streams.stream(values.getValues()).collect(toList()));
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
index c10edc2..0b2a346 100644
--- a/java/com/google/gerrit/entities/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -15,23 +15,8 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.util.Objects;
 
-/** Named group of one or more accounts, typically used for access controls. */
 public final class AccountGroup {
-  /**
-   * Time when the audit subsystem was implemented, used as the default value for {@link #createdOn}
-   * when one couldn't be determined from the audit log.
-   */
-  private static final Instant AUDIT_CREATION_INSTANT_MS = Instant.ofEpochMilli(1244489460000L);
-
-  public static Timestamp auditCreationInstantTs() {
-    return Timestamp.from(AUDIT_CREATION_INSTANT_MS);
-  }
-
   public static NameKey nameKey(String n) {
     return new AutoValue_AccountGroup_NameKey(n);
   }
@@ -69,6 +54,11 @@
       return uuid();
     }
 
+    /** @return true if the UUID is for a group managed within Gerrit. */
+    public boolean isInternalGroup() {
+      return get().matches("^[0-9a-f]{40}$");
+    }
+
     /** Parse an {@link AccountGroup.UUID} out of a string representation. */
     public static UUID parse(String str) {
       return AccountGroup.uuid(KeyUtil.decode(str));
@@ -107,11 +97,6 @@
     }
   }
 
-  /** @return true if the UUID is for a group managed within Gerrit. */
-  public static boolean isInternalGroup(AccountGroup.UUID uuid) {
-    return uuid.get().matches("^[0-9a-f]{40}$");
-  }
-
   public static Id id(int id) {
     return new AutoValue_AccountGroup_Id(id);
   }
@@ -135,158 +120,4 @@
       return Integer.toString(get());
     }
   }
-
-  /** Unique name of this group within the system. */
-  protected NameKey name;
-
-  /** Unique identity, to link entities as {@link #name} can change. */
-  protected Id groupId;
-
-  // DELETED: id = 3 (ownerGroupId)
-
-  /** A textual description of the group's purpose. */
-  @Nullable protected String description;
-
-  // DELETED: id = 5 (groupType)
-  // DELETED: id = 6 (externalName)
-
-  protected boolean visibleToAll;
-
-  // DELETED: id = 8 (emailOnlyAuthors)
-
-  /** Globally unique identifier name for this group. */
-  protected UUID groupUUID;
-
-  /**
-   * Identity of the group whose members can manage this group.
-   *
-   * <p>This can be a self-reference to indicate the group's members manage itself.
-   */
-  protected UUID ownerGroupUUID;
-
-  @Nullable protected Timestamp createdOn;
-
-  protected AccountGroup() {}
-
-  public AccountGroup(
-      AccountGroup.NameKey newName,
-      AccountGroup.Id newId,
-      AccountGroup.UUID uuid,
-      Timestamp createdOn) {
-    name = newName;
-    groupId = newId;
-    visibleToAll = false;
-    groupUUID = uuid;
-    ownerGroupUUID = groupUUID;
-    this.createdOn = createdOn;
-  }
-
-  public AccountGroup(AccountGroup other) {
-    name = other.name;
-    groupId = other.groupId;
-    description = other.description;
-    visibleToAll = other.visibleToAll;
-    groupUUID = other.groupUUID;
-    ownerGroupUUID = other.ownerGroupUUID;
-    createdOn = other.createdOn;
-  }
-
-  public AccountGroup.Id getId() {
-    return groupId;
-  }
-
-  public String getName() {
-    return name.get();
-  }
-
-  public AccountGroup.NameKey getNameKey() {
-    return name;
-  }
-
-  public void setNameKey(AccountGroup.NameKey nameKey) {
-    name = nameKey;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String d) {
-    description = d;
-  }
-
-  public AccountGroup.UUID getOwnerGroupUUID() {
-    return ownerGroupUUID;
-  }
-
-  public void setOwnerGroupUUID(AccountGroup.UUID uuid) {
-    ownerGroupUUID = uuid;
-  }
-
-  public void setVisibleToAll(boolean visibleToAll) {
-    this.visibleToAll = visibleToAll;
-  }
-
-  public boolean isVisibleToAll() {
-    return visibleToAll;
-  }
-
-  public AccountGroup.UUID getGroupUUID() {
-    return groupUUID;
-  }
-
-  public void setGroupUUID(AccountGroup.UUID uuid) {
-    groupUUID = uuid;
-  }
-
-  public Timestamp getCreatedOn() {
-    return createdOn != null ? createdOn : auditCreationInstantTs();
-  }
-
-  public void setCreatedOn(Timestamp createdOn) {
-    this.createdOn = createdOn;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof AccountGroup)) {
-      return false;
-    }
-    AccountGroup g = (AccountGroup) o;
-    return Objects.equals(name, g.name)
-        && Objects.equals(groupId, g.groupId)
-        && Objects.equals(description, g.description)
-        && visibleToAll == g.visibleToAll
-        && Objects.equals(groupUUID, g.groupUUID)
-        && Objects.equals(ownerGroupUUID, g.ownerGroupUUID)
-        // Treat created on epoch identical regardless if underlying value is null.
-        && getCreatedOn().equals(g.getCreatedOn());
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(
-        name, groupId, description, visibleToAll, groupUUID, ownerGroupUUID, createdOn);
-  }
-
-  @Override
-  public String toString() {
-    return getClass().getSimpleName()
-        + "{"
-        + "name="
-        + name
-        + ", groupId="
-        + groupId
-        + ", description="
-        + description
-        + ", visibleToAll="
-        + visibleToAll
-        + ", groupUUID="
-        + groupUUID
-        + ", ownerGroupUUID="
-        + ownerGroupUUID
-        + ", createdOn="
-        + createdOn
-        + "}";
-  }
 }
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 739bd38..c768094 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -26,6 +26,7 @@
 import java.security.SecureRandom;
 import java.sql.Timestamp;
 import java.util.Arrays;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -116,13 +117,30 @@
 
   @AutoValue
   public abstract static class Id {
-    /** Parse a Change.Id out of a string representation. */
+    /**
+     * Parse a Change.Id out of a string representation.
+     *
+     * @deprecated use {@link #tryParse(String)} instead.
+     */
+    @Deprecated
     public static Id parse(String str) {
       Integer id = Ints.tryParse(str);
       checkArgument(id != null, "invalid change ID: %s", str);
       return Change.id(id);
     }
 
+    /**
+     * Parse a Change.Id out of a string representation.
+     *
+     * @param str the string to parse
+     * @return Optional containing the Change.Id, or {@code Optional.empty()} if str does not
+     *     represent a valid Change.Id.
+     */
+    public static Optional<Id> tryParse(String str) {
+      Integer id = Ints.tryParse(str);
+      return id != null ? Optional.of(Change.id(id)) : Optional.empty();
+    }
+
     public static Id fromRef(String ref) {
       if (RefNames.isRefsEdit(ref)) {
         return fromEditRefPart(ref);
@@ -513,6 +531,9 @@
   /** References a change that this change reverts. */
   @Nullable protected Id revertOf;
 
+  /** References the source change and patchset that this change was cherry-picked from. */
+  @Nullable protected PatchSet.Id cherryPickOf;
+
   protected Change() {}
 
   public Change(
@@ -549,6 +570,7 @@
     workInProgress = other.workInProgress;
     reviewStarted = other.reviewStarted;
     revertOf = other.revertOf;
+    cherryPickOf = other.cherryPickOf;
   }
 
   /** Legacy 32 bit integer identity for a change. */
@@ -742,6 +764,14 @@
     return this.revertOf;
   }
 
+  public PatchSet.Id getCherryPickOf() {
+    return cherryPickOf;
+  }
+
+  public void setCherryPickOf(@Nullable PatchSet.Id cherryPickOf) {
+    this.cherryPickOf = cherryPickOf;
+  }
+
   @Override
   public String toString() {
     return new StringBuilder(getClass().getSimpleName())
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 55d739a..9c58fef 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -29,6 +29,8 @@
  *
  * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
  * require a corresponding data migration (adding new optional fields is generally okay).
+ *
+ * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
  */
 public class Comment {
   public enum Status {
@@ -56,7 +58,7 @@
     }
   }
 
-  public static class Key {
+  public static final class Key {
     public String uuid;
     public String filename;
     public int patchSetId;
@@ -97,7 +99,7 @@
     }
   }
 
-  public static class Identity {
+  public static final class Identity {
     int id;
 
     public Identity(Account.Id id) {
@@ -147,7 +149,7 @@
    *       </ul>
    * </ul>
    */
-  public static class Range implements Comparable<Range> {
+  public static final class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
         Comparator.<Range>comparingInt(range -> range.startLine)
             .thenComparingInt(range -> range.startChar)
@@ -220,10 +222,12 @@
   public Range range;
   public String tag;
 
-  // Hex commit SHA1 of the commit of the patchset to which this comment applies. Other classes call
-  // this "commitId", but this class uses the old ReviewDb term "revId", and this field name is
-  // serialized into JSON in NoteDb, so it can't easily be changed. Callers do not access this field
-  // directly, and instead use the public getter/setter that wraps an ObjectId.
+  /**
+   * Hex commit SHA1 of the commit of the patchset to which this comment applies. Other classes call
+   * this "commitId", but this class uses the old ReviewDb term "revId", and this field name is
+   * serialized into JSON in NoteDb, so it can't easily be changed. Callers do not access this field
+   * directly, and instead use the public getter/setter that wraps an ObjectId.
+   */
   private String revId;
 
   public String serverId;
@@ -293,6 +297,23 @@
     return realAuthor != null ? realAuthor : author;
   }
 
+  /**
+   * Returns the comment's approximate size. This is used to enforce size limits and should
+   * therefore include all unbounded fields (e.g. String-s).
+   */
+  public int getApproximateSize() {
+    return nullableLength(message, parentUuid, tag, revId, serverId)
+        + (key != null ? nullableLength(key.filename, key.uuid) : 0);
+  }
+
+  static int nullableLength(String... strings) {
+    int length = 0;
+    for (String s : strings) {
+      length += s == null ? 0 : s.length();
+    }
+    return length;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof Comment)) {
diff --git a/java/com/google/gerrit/entities/FixReplacement.java b/java/com/google/gerrit/entities/FixReplacement.java
index 046300e..fbbf746 100644
--- a/java/com/google/gerrit/entities/FixReplacement.java
+++ b/java/com/google/gerrit/entities/FixReplacement.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.entities;
 
-public class FixReplacement {
-  public String path;
-  public Comment.Range range;
-  public String replacement;
+public final class FixReplacement {
+  public final String path;
+  public final Comment.Range range;
+  public final String replacement;
 
   public FixReplacement(String path, Comment.Range range, String replacement) {
     this.path = path;
@@ -38,4 +38,9 @@
         + '\''
         + '}';
   }
+
+  /** Returns this instance's approximate size in bytes for the purpose of applying size limits. */
+  int getApproximateSize() {
+    return path.length() + replacement.length();
+  }
 }
diff --git a/java/com/google/gerrit/entities/FixSuggestion.java b/java/com/google/gerrit/entities/FixSuggestion.java
index ac4e720..c88afe7 100644
--- a/java/com/google/gerrit/entities/FixSuggestion.java
+++ b/java/com/google/gerrit/entities/FixSuggestion.java
@@ -16,10 +16,10 @@
 
 import java.util.List;
 
-public class FixSuggestion {
-  public String fixId;
-  public String description;
-  public List<FixReplacement> replacements;
+public final class FixSuggestion {
+  public final String fixId;
+  public final String description;
+  public final List<FixReplacement> replacements;
 
   public FixSuggestion(String fixId, String description, List<FixReplacement> replacements) {
     this.fixId = fixId;
@@ -40,4 +40,11 @@
         + replacements
         + '}';
   }
+
+  /** Returns this instance's approximate size in bytes for the purpose of applying size limits. */
+  int getApproximateSize() {
+    return fixId.length()
+        + description.length()
+        + replacements.stream().map(FixReplacement::getApproximateSize).reduce(0, Integer::sum);
+  }
 }
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 8b93dbc..4a33bd7 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -124,13 +124,17 @@
       return id();
     }
 
+    public String getCommaSeparatedChangeAndPatchSetId() {
+      return changeId().toString() + ',' + id();
+    }
+
     public String toRefName() {
       return changeId().refPrefixBuilder().append(id()).toString();
     }
 
     @Override
     public final String toString() {
-      return changeId().toString() + ',' + id();
+      return getCommaSeparatedChangeAndPatchSetId();
     }
   }
 
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index a7951ad..4a17566 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -19,7 +19,7 @@
 import java.util.Map;
 import java.util.Objects;
 
-public class RobotComment extends Comment {
+public final class RobotComment extends Comment {
   public String robotId;
   public String robotRunId;
   public String url;
@@ -41,6 +41,22 @@
   }
 
   @Override
+  public int getApproximateSize() {
+    int approximateSize = super.getApproximateSize() + nullableLength(robotId, robotRunId, url);
+    approximateSize +=
+        properties != null
+            ? properties.entrySet().stream()
+                .map(entry -> nullableLength(entry.getKey(), entry.getValue()))
+                .reduce(0, Integer::sum)
+            : 0;
+    approximateSize +=
+        fixSuggestions != null
+            ? fixSuggestions.stream().map(FixSuggestion::getApproximateSize).reduce(0, Integer::sum)
+            : 0;
+    return approximateSize;
+  }
+
+  @Override
   public String toString() {
     return toStringHelper()
         .add("robotId", robotId)
diff --git a/java/com/google/gerrit/entities/SubmissionId.java b/java/com/google/gerrit/entities/SubmissionId.java
new file mode 100644
index 0000000..eb03a5a
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmissionId.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import org.eclipse.jgit.annotations.Nullable;
+
+public class SubmissionId {
+  private final String submissionId;
+
+  public SubmissionId(Change.Id changeId, @Nullable String topic) {
+    submissionId = topic != null ? String.format("%s-%s", changeId, topic) : changeId.toString();
+  }
+
+  public SubmissionId(Change change) {
+    this(change.getId(), change.getTopic());
+  }
+
+  @Override
+  public String toString() {
+    return submissionId;
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 5b066ea..25e68f9 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -29,6 +29,8 @@
 
   private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
       ChangeIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
   private final ProtoConverter<Entities.Change_Key, Change.Key> changeKeyConverter =
       ChangeKeyProtoConverter.INSTANCE;
   private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
@@ -78,6 +80,10 @@
     if (revertOf != null) {
       builder.setRevertOf(changeIdConverter.toProto(revertOf));
     }
+    PatchSet.Id cherryPickOf = change.getCherryPickOf();
+    if (cherryPickOf != null) {
+      builder.setCherryPickOf(patchSetIdConverter.toProto(cherryPickOf));
+    }
     return builder.build();
   }
 
@@ -118,6 +124,9 @@
     if (proto.hasRevertOf()) {
       change.setRevertOf(changeIdConverter.fromProto(proto.getRevertOf()));
     }
+    if (proto.hasCherryPickOf()) {
+      change.setCherryPickOf(patchSetIdConverter.fromProto(proto.getCherryPickOf()));
+    }
     return change;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/NoteDbTable.java b/java/com/google/gerrit/exceptions/InvalidMergeStrategyException.java
similarity index 63%
copy from java/com/google/gerrit/server/notedb/NoteDbTable.java
copy to java/com/google/gerrit/exceptions/InvalidMergeStrategyException.java
index e299fdf..d9c5776 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/java/com/google/gerrit/exceptions/InvalidMergeStrategyException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,19 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
+package com.google.gerrit.exceptions;
 
-public enum NoteDbTable {
-  ACCOUNTS,
-  GROUPS,
-  CHANGES;
+public class InvalidMergeStrategyException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-  public String key() {
-    return name().toLowerCase();
-  }
-
-  @Override
-  public String toString() {
-    return key();
+  public InvalidMergeStrategyException(String strategy) {
+    super("invalid merge strategy: " + strategy);
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 26a1a27..96455a6 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -161,6 +162,12 @@
    */
   ChangeApi revert(RevertInput in) throws RestApiException;
 
+  default RevertSubmissionInfo revertSubmission() throws RestApiException {
+    return revertSubmission(new RevertInput());
+  }
+
+  RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException;
+
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
@@ -252,17 +259,12 @@
    *
    * <ul>
    *   <li>{@code CHECK} is omitted, to skip consistency checks.
-   *   <li>{@code SKIP_MERGEABLE} is omitted, so the {@code mergeable} bit <em>is</em> set.
    *   <li>{@code SKIP_DIFFSTAT} is omitted to ensure diffstat calculations.
    * </ul>
    */
   default ChangeInfo get() throws RestApiException {
     return get(
-        EnumSet.complementOf(
-            EnumSet.of(
-                ListChangesOption.CHECK,
-                ListChangesOption.SKIP_MERGEABLE,
-                ListChangesOption.SKIP_DIFFSTAT)));
+        EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_DIFFSTAT)));
   }
 
   /** {@link #get(ListChangesOption...)} with no options included. */
@@ -502,6 +504,11 @@
     }
 
     @Override
+    public RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void rebase(RebaseInput in) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
index 25eb7a8..6bd4b73 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -150,7 +150,22 @@
    * @param newContent the desired content of the file
    * @throws RestApiException if the content of the file couldn't be modified
    */
-  void modifyFile(String filePath, RawInput newContent) throws RestApiException;
+  default void modifyFile(String filePath, RawInput newContent) throws RestApiException {
+    FileContentInput input = new FileContentInput();
+    input.content = newContent;
+    modifyFile(filePath, input);
+  }
+
+  /**
+   * Modify the contents of the specified file of the change edit. If no content is provided, the
+   * content of the file is erased but the file isn't deleted. If the change edit doesn't exist, it
+   * will be created based on the current patch set of the change.
+   *
+   * @param filePath the path of the file which should be modified
+   * @param input the desired content of the file
+   * @throws RestApiException if the content of the file couldn't be modified
+   */
+  void modifyFile(String filePath, FileContentInput input) throws RestApiException;
 
   /**
    * Deletes the specified file from the change edit. If the change edit doesn't exist, it will be
@@ -235,7 +250,7 @@
     }
 
     @Override
-    public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
+    public void modifyFile(String filePath, FileContentInput input) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/server/notedb/NoteDbTable.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
similarity index 60%
copy from java/com/google/gerrit/server/notedb/NoteDbTable.java
copy to java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index e299fdf..93c253d 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,19 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
+package com.google.gerrit.extensions.api.changes;
 
-public enum NoteDbTable {
-  ACCOUNTS,
-  GROUPS,
-  CHANGES;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.RawInput;
 
-  public String key() {
-    return name().toLowerCase();
-  }
-
-  @Override
-  public String toString() {
-    return key();
-  }
+/** Content to be added to a file (new or existing) via change edit. */
+public class FileContentInput {
+  @DefaultInput public RawInput content;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index f8404ce..7d4f555 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.CherryPickChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
@@ -125,6 +126,8 @@
    */
   EditInfo applyFix(String fixId) throws RestApiException;
 
+  Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -296,6 +299,11 @@
     }
 
     @Override
+    public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java b/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java
index b0f674f..b24eca0 100644
--- a/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BanCommitInput.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.Lists;
 import java.util.List;
 
+/** Commits that will forbidden to be uploaded. */
 public class BanCommitInput {
   public List<String> commits;
   public String reason;
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
new file mode 100644
index 0000000..3aad7e1
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+/*
+ * Input for a commentlink configuration on a project.
+ */
+public class CommentLinkInput {
+  /** A JavaScript regular expression to match positions to be replaced with a hyperlink. */
+  public String match;
+  /** The URL to direct the user to whenever the regular expression is matched. */
+  public String link;
+  /** Whether the commentlink is enabled. */
+  public Boolean enabled;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 1a6d77b..8005fc5 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -38,4 +38,5 @@
   public SubmitType submitType;
   public ProjectState state;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
+  public Map<String, CommentLinkInput> commentLinks;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/LabelApi.java b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
new file mode 100644
index 0000000..975a57e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface LabelApi {
+  LabelApi create(LabelDefinitionInput input) throws RestApiException;
+
+  LabelDefinitionInfo get() throws RestApiException;
+
+  LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException;
+
+  default void delete() throws RestApiException {
+    delete(null);
+  }
+
+  void delete(@Nullable String commitMessage) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements LabelApi {
+    @Override
+    public LabelApi create(LabelDefinitionInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public LabelDefinitionInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete(@Nullable String commitMessage) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ParentInput.java b/java/com/google/gerrit/extensions/api/projects/ParentInput.java
index 6e481ae..d68bb3b 100644
--- a/java/com/google/gerrit/extensions/api/projects/ParentInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ParentInput.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
-public class ParentInput {
+public class ParentInput extends InputWithCommitMessage {
   @DefaultInput public String parent;
-  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index c6d9dee..9873995 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
 import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -204,6 +206,28 @@
   /** Reindexes all changes of the project. */
   void indexChanges() throws RestApiException;
 
+  ListLabelsRequest labels() throws RestApiException;
+
+  abstract class ListLabelsRequest {
+    protected boolean inherited;
+
+    public abstract List<LabelDefinitionInfo> get() throws RestApiException;
+
+    public ListLabelsRequest withInherited(boolean inherited) {
+      this.inherited = inherited;
+      return this;
+    }
+  }
+
+  LabelApi label(String labelName) throws RestApiException;
+
+  /**
+   * Adds, updates and deletes label definitions in a batch.
+   *
+   * @param input input that describes additions, updates and deletions of label definitions
+   */
+  void labels(BatchLabelInput input) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -378,5 +402,20 @@
     public void indexChanges() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ListLabelsRequest labels() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public LabelApi label(String labelName) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void labels(BatchLabelInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
index 0083c0e..3662b7f 100644
--- a/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/SetDashboardInput.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
-public class SetDashboardInput {
+public class SetDashboardInput extends InputWithCommitMessage {
   @DefaultInput public String id;
-  public String commitMessage;
 }
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 425265b..6071cc7 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -74,7 +74,11 @@
   /** If tracking Ids are included, include detailed tracking Ids info. */
   TRACKING_IDS(21),
 
-  /** Skip mergeability data */
+  /**
+   * Use {@code gerrit.config} instead to turn this off for your instance. See {@code
+   * change.mergeabilityComputationBehavior}.
+   */
+  @Deprecated
   SKIP_MERGEABLE(22),
 
   /**
diff --git a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
index 3ffa97a..a2aeab2 100644
--- a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
@@ -16,7 +16,17 @@
 
 import java.sql.Timestamp;
 
+/**
+ * Representation of a (detailed) account in the REST API.
+ *
+ * <p>This class determines the JSON format of (detailed) accounts in the REST API.
+ *
+ * <p>This class extends {@link AccountInfo} (which defines fields for account properties that are
+ * frequently used) and provides additional fields for account details which are needed only in some
+ * cases.
+ */
 public class AccountDetailInfo extends AccountInfo {
+  /** The timestamp of when the account was registered. */
   public Timestamp registeredOn;
 
   public AccountDetailInfo(Integer id) {
diff --git a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index 9e6770b..e3e0fc8 100644
--- a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -19,10 +19,30 @@
 import com.google.common.collect.ComparisonChain;
 import java.util.Objects;
 
+/**
+ * Representation of an external ID in the REST API.
+ *
+ * <p>This class determines the JSON format of external IDs in the REST API.
+ *
+ * <p>External IDs are user identities that are assigned to an account. Often they are used to link
+ * user identities in external systems.
+ */
 public class AccountExternalIdInfo implements Comparable<AccountExternalIdInfo> {
+  /** The external ID key, formatted as {@code <scheme>:<ID>}. */
   public String identity;
+
+  /** The email address of the external ID. */
   public String emailAddress;
+
+  /**
+   * Whether the external ID is trusted.
+   *
+   * <p>Also see {@link
+   * com.google.gerrit.server.config.AuthConfig#isIdentityTrustable(java.util.Collection)}.
+   */
   public Boolean trusted;
+
+  /** Whether the external ID can be deleted by the calling user. */
   public Boolean canDelete;
 
   @Override
diff --git a/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
index d1bbe88..d39d99a 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -18,15 +18,43 @@
 import java.util.List;
 import java.util.Objects;
 
+/**
+ * Representation of an account in the REST API.
+ *
+ * <p>This class determines the JSON format of accounts in the REST API.
+ *
+ * <p>This class defines fields for account properties that are frequently used. Additional fields
+ * are defined in {@link AccountDetailInfo}.
+ */
 public class AccountInfo {
+  /** The numeric ID of the account. */
   public Integer _accountId;
+
+  /** The full name of the user. */
   public String name;
+
+  /** The preferred email address of the user. */
   public String email;
+
+  /** List of the secondary email addresses of the user. */
   public List<String> secondaryEmails;
+
+  /** The username of the user. */
   public String username;
+
+  /** List of avatars of the user. */
   public List<AvatarInfo> avatars;
+
+  /**
+   * Whether the query would deliver more results if not limited. Only set on the last account that
+   * is returned as a query result.
+   */
   public Boolean _moreAccounts;
+
+  /** Status message of the account (e.g. 'OOO' for out-of-office). */
   public String status;
+
+  /** Whether the account is inactive. */
   public Boolean inactive;
 
   public AccountInfo(Integer id) {
diff --git a/java/com/google/gerrit/extensions/common/AccountsInfo.java b/java/com/google/gerrit/extensions/common/AccountsInfo.java
index e1c2825..a2a4826 100644
--- a/java/com/google/gerrit/extensions/common/AccountsInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountsInfo.java
@@ -14,6 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
+/**
+ * Representation of account-related server configuration in the REST API.
+ *
+ * <p>This class determines the JSON format of account-related server configuration in the REST API.
+ */
 public class AccountsInfo {
+  /** The value of the {@code accounts.visibility} parameter in {@code gerrit.config}. */
   public AccountVisibility visibility;
 }
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 0953ee9..6ab80b2 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -16,10 +16,37 @@
 
 import com.google.gerrit.extensions.webui.UiAction;
 
+/**
+ * Representation of an action in the REST API.
+ *
+ * <p>This class determines the JSON format of actions in the REST API.
+ *
+ * <p>An action describes a REST API call the client can make to manipulate a resource. These are
+ * frequently implemented by plugins and may be discovered at runtime.
+ */
 public class ActionInfo {
+  /**
+   * HTTP method to use with the action. Most actions use {@code POST}, {@code PUT} or {@code
+   * DELETE} to cause state changes.
+   */
   public String method;
+
+  /**
+   * Short title to display to a user describing the action. In the Gerrit web interface the label
+   * is used as the text on the button that is presented in the UI.
+   */
   public String label;
+
+  /**
+   * Longer text to display describing the action. In a web UI this should be the title attribute of
+   * the element, displaying when the user hovers the mouse.
+   */
   public String title;
+
+  /**
+   * If {@code true} the action is permitted at this time and the caller is likely allowed to
+   * execute it. This may change if state is updated at the server or permissions are modified.
+   */
   public Boolean enabled;
 
   public ActionInfo(UiAction.Description d) {
diff --git a/java/com/google/gerrit/extensions/common/AgreementInfo.java b/java/com/google/gerrit/extensions/common/AgreementInfo.java
index 4242fcd..c092463 100644
--- a/java/com/google/gerrit/extensions/common/AgreementInfo.java
+++ b/java/com/google/gerrit/extensions/common/AgreementInfo.java
@@ -14,9 +14,25 @@
 
 package com.google.gerrit.extensions.common;
 
+/**
+ * Representation of a contributor agreement in the REST API.
+ *
+ * <p>This class determines the JSON format of a contributor agreement in the REST API.
+ */
 public class AgreementInfo {
+  /** The unique name of the contributor agreement. */
   public String name;
+
+  /** The description of the contributor agreement. */
   public String description;
+
+  /** The URL of the contributor agreement. */
   public String url;
+
+  /**
+   * Group to which a user that signs the contributor agreement online is added automatically.
+   *
+   * <p>May be {@code null}. In this case users cannot sign the contributor agreement online.
+   */
   public GroupInfo autoVerifyGroup;
 }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index e40004b..f95ddff 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -17,11 +17,42 @@
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 
+/**
+ * Representation of an approval in the REST API.
+ *
+ * <p>This class determines the JSON format of approvals in the REST API.
+ *
+ * <p>An approval is a vote of a user for a label on a change.
+ */
 public class ApprovalInfo extends AccountInfo {
+  /**
+   * Tag that was set when posting the review that created this approval.
+   *
+   * <p>Web UIs may use the tag to filter out approvals, e.g. initially hide approvals that have a
+   * tag that starts with the {@code autogenerated:} prefix.
+   */
   public String tag;
+
+  /**
+   * The vote that the user has given for the label.
+   *
+   * <p>If present and zero, the user is permitted to vote on the label. If absent, the user is not
+   * permitted to vote on that label.
+   */
   public Integer value;
+
+  /** The time and date describing when the approval was made. */
   public Timestamp date;
+
+  /** Whether this vote was made after the change was submitted. */
   public Boolean postSubmit;
+
+  /**
+   * The range the user is authorized to vote on that label.
+   *
+   * <p>If present, the user is permitted to vote on the label regarding the range values. If
+   * absent, the user is not permitted to vote on that label.
+   */
   public VotingRangeInfo permittedVotingRange;
 
   public ApprovalInfo(Integer id) {
diff --git a/java/com/google/gerrit/extensions/common/AuthInfo.java b/java/com/google/gerrit/extensions/common/AuthInfo.java
index 79c2250..3aa40fc 100644
--- a/java/com/google/gerrit/extensions/common/AuthInfo.java
+++ b/java/com/google/gerrit/extensions/common/AuthInfo.java
@@ -19,17 +19,104 @@
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import java.util.List;
 
+/**
+ * Representation of auth-related server configuration in the REST API.
+ *
+ * <p>This class determines the JSON format of auth-related server configuration in the REST API.
+ *
+ * <p>The contained values come from the {@code auth} section of {@code gerrit.config}.
+ */
 public class AuthInfo {
+  /**
+   * The authentication type that is configured on the server.
+   *
+   * <p>The value of the {@code auth.type} parameter in {@code gerrit.config}.
+   */
   public AuthType authType;
+
+  /**
+   * Whether contributor agreements are required.
+   *
+   * <p>The value of the {@code auth.contributorAgreements} parameter in {@code gerrit.config}.
+   */
   public Boolean useContributorAgreements;
+
+  /** List of contributor agreements that have been configured on the server. */
   public List<AgreementInfo> contributorAgreements;
+
+  /** List of account fields that are editable. */
   public List<AccountFieldName> editableAccountFields;
+
+  /**
+   * The login URL.
+   *
+   * <p>The value of the {@code auth.loginUrl} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code HTTP} or {@code HTTP_LDAP}.
+   */
   public String loginUrl;
+
+  /**
+   * The login text.
+   *
+   * <p>The value of the {@code auth.loginText} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code HTTP} or {@code HTTP_LDAP}.
+   */
   public String loginText;
+
+  /**
+   * The URL to switch accounts.
+   *
+   * <p>The value of the {@code auth.switchAccountUrl} parameter in {@code gerrit.config}.
+   */
   public String switchAccountUrl;
+
+  /**
+   * The register URL.
+   *
+   * <p>The value of the {@code auth.registerUrl} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code LDAP}, {@code LDAP_BIND} or {@code
+   * CUSTOM_EXTENSION}.
+   */
   public String registerUrl;
+
+  /**
+   * The register text.
+   *
+   * <p>The value of the {@code auth.registerText} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code LDAP}, {@code LDAP_BIND} or {@code
+   * CUSTOM_EXTENSION}.
+   */
   public String registerText;
+
+  /**
+   * The URL to edit the full name.
+   *
+   * <p>The value of the {@code auth.editFullNameUrl} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code LDAP}, {@code LDAP_BIND} or {@code
+   * CUSTOM_EXTENSION}.
+   */
   public String editFullNameUrl;
+
+  /**
+   * The URL to obtain an HTTP password.
+   *
+   * <p>The value of the {@code auth.httpPasswordUrl} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code CUSTOM_EXTENSION}.
+   */
   public String httpPasswordUrl;
+
+  /**
+   * The policy to authenticate Git over HTTP and REST API requests.
+   *
+   * <p>The value of the {@code auth.gitBasicAuthPolicy} parameter in {@code gerrit.config}.
+   *
+   * <p>Only set if authentication type is {@code LDAP}, {@code LDAP_BIND} or {@code OAUTH}.
+   */
   public GitBasicAuthPolicy gitBasicAuthPolicy;
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index de609eb..75665a8 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -14,6 +14,13 @@
 
 package com.google.gerrit.extensions.common;
 
+/**
+ * Representation of an avatar in the REST API.
+ *
+ * <p>This class determines the JSON format of avatars in the REST API.
+ *
+ * <p>An avatar is the graphical representation of a user.
+ */
 public class AvatarInfo {
   /**
    * Size in pixels the UI prefers an avatar image to be.
@@ -23,7 +30,12 @@
    */
   public static final int DEFAULT_SIZE = 32;
 
+  /** The URL to the avatar image. */
   public String url;
+
+  /** The height of the avatar image in pixels. */
   public Integer height;
+
+  /** The width of the avatar image in pixels. */
   public Integer width;
 }
diff --git a/java/com/google/gerrit/extensions/common/BatchLabelInput.java b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
new file mode 100644
index 0000000..eb4c581
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+/** Input for the REST API that describes additions, updates and deletions of label definitions. */
+public class BatchLabelInput {
+  public String commitMessage;
+  public List<String> delete;
+  public List<LabelDefinitionInput> create;
+  public Map<String, LabelDefinitionInput> update;
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index e8aeb40..66a2dd5 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+/** API response containing values from the {@code change} section of {@code gerrit.config}. */
 public class ChangeConfigInfo {
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
@@ -23,5 +24,5 @@
   public String replyTooltip;
   public int updateDelay;
   public Boolean submitWholeTopic;
-  public Boolean excludeMergeableInChangeInfo;
+  public String mergeabilityComputationBehavior;
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 9a739ef..3b3f2ad 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -52,6 +52,9 @@
   public Boolean workInProgress;
   public Boolean hasReviewStarted;
   public Integer revertOf;
+  public String submissionId;
+  public Integer cherryPickOfChange;
+  public Integer cherryPickOfPatchSet;
 
   public int _number;
 
diff --git a/java/com/google/gerrit/extensions/common/DownloadInfo.java b/java/com/google/gerrit/extensions/common/DownloadInfo.java
index 5ea5992..ea94e4d 100644
--- a/java/com/google/gerrit/extensions/common/DownloadInfo.java
+++ b/java/com/google/gerrit/extensions/common/DownloadInfo.java
@@ -17,6 +17,7 @@
 import java.util.List;
 import java.util.Map;
 
+/** API response containing values from the {@code download} section of {@code gerrit.config}. */
 public class DownloadInfo {
   public Map<String, DownloadSchemeInfo> schemes;
   public List<String> archives;
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 5c462d9..2ae6703 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+/** API response containing values from the {@code gerrit} section of {@code gerrit.config}. */
 public class GerritInfo {
   public String allProjects;
   public String allUsers;
diff --git a/java/com/google/gerrit/extensions/common/InputWithCommitMessage.java b/java/com/google/gerrit/extensions/common/InputWithCommitMessage.java
new file mode 100644
index 0000000..34bc203
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/InputWithCommitMessage.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.common.Nullable;
+
+/** A generic input with a commit message only. */
+public class InputWithCommitMessage {
+  @Nullable public String commitMessage;
+
+  public InputWithCommitMessage() {
+    this(null);
+  }
+
+  public InputWithCommitMessage(@Nullable String commitMessage) {
+    this.commitMessage = commitMessage;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/InputWithMessage.java b/java/com/google/gerrit/extensions/common/InputWithMessage.java
new file mode 100644
index 0000000..45d23cf
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/InputWithMessage.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.common.Nullable;
+
+/**
+ * A generic input with a message only.
+ *
+ * <p>See also {@link InputWithCommitMessage}.
+ */
+public class InputWithMessage {
+  @Nullable public String message;
+
+  public InputWithMessage() {
+    this(null);
+  }
+
+  public InputWithMessage(@Nullable String message) {
+    this.message = message;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
new file mode 100644
index 0000000..64c3997
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+public class LabelDefinitionInfo {
+  public String name;
+  public String projectName;
+  public String function;
+  public Map<String, String> values;
+  public short defaultValue;
+  public List<String> branches;
+  public Boolean canOverride;
+  public Boolean copyAnyScore;
+  public Boolean copyMinScore;
+  public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfNoChange;
+  public Boolean copyAllScoresIfNoCodeChange;
+  public Boolean copyAllScoresOnTrivialRebase;
+  public Boolean copyAllScoresOnMergeFirstParentUpdate;
+  public Boolean allowPostSubmit;
+  public Boolean ignoreSelfApproval;
+}
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
new file mode 100644
index 0000000..0523f61
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+import java.util.Map;
+
+public class LabelDefinitionInput extends InputWithCommitMessage {
+  public String name;
+  public String function;
+  public Map<String, String> values;
+  public Short defaultValue;
+  public List<String> branches;
+  public Boolean canOverride;
+  public Boolean copyAnyScore;
+  public Boolean copyMinScore;
+  public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfNoChange;
+  public Boolean copyAllScoresIfNoCodeChange;
+  public Boolean copyAllScoresOnTrivialRebase;
+  public Boolean copyAllScoresOnMergeFirstParentUpdate;
+  public Boolean allowPostSubmit;
+  public Boolean ignoreSelfApproval;
+}
diff --git a/java/com/google/gerrit/extensions/common/MergeInput.java b/java/com/google/gerrit/extensions/common/MergeInput.java
index c16a551..c3cfcee 100644
--- a/java/com/google/gerrit/extensions/common/MergeInput.java
+++ b/java/com/google/gerrit/extensions/common/MergeInput.java
@@ -24,6 +24,12 @@
   public String source;
 
   /**
+   * If specified, visibility of the {@code source} commit will only be checked against {@code
+   * source_branch}, rather than all visible branches.
+   */
+  public String sourceBranch;
+
+  /**
    * {@code strategy} name of the merge strategy.
    *
    * @see org.eclipse.jgit.merge.MergeStrategy
diff --git a/java/com/google/gerrit/extensions/common/ReceiveInfo.java b/java/com/google/gerrit/extensions/common/ReceiveInfo.java
index 9fcd92b..23e45ae 100644
--- a/java/com/google/gerrit/extensions/common/ReceiveInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReceiveInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+/** API response containing values from the {@code receive} section of {@code gerrit.config}. */
 public class ReceiveInfo {
   public Boolean enableSignedPush;
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbTable.java b/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
similarity index 66%
rename from java/com/google/gerrit/server/notedb/NoteDbTable.java
rename to java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
index e299fdf..dabd035 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/java/com/google/gerrit/extensions/common/RevertSubmissionInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,19 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
+package com.google.gerrit.extensions.common;
 
-public enum NoteDbTable {
-  ACCOUNTS,
-  GROUPS,
-  CHANGES;
+import java.util.List;
 
-  public String key() {
-    return name().toLowerCase();
-  }
-
-  @Override
-  public String toString() {
-    return key();
-  }
+public class RevertSubmissionInfo {
+  public List<ChangeInfo> revertChanges;
 }
diff --git a/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
index 82d5bc8..bc7fcfd 100644
--- a/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+/** API response containing values from {@code gerrit.config} as nested objects. */
 public class ServerInfo {
   public AccountsInfo accounts;
   public AuthInfo auth;
diff --git a/java/com/google/gerrit/extensions/common/SshdInfo.java b/java/com/google/gerrit/extensions/common/SshdInfo.java
index fb9cb16..7242f98 100644
--- a/java/com/google/gerrit/extensions/common/SshdInfo.java
+++ b/java/com/google/gerrit/extensions/common/SshdInfo.java
@@ -14,4 +14,10 @@
 
 package com.google.gerrit.extensions.common;
 
+/**
+ * API response containing values from the {@code sshd} section of {@code gerrit.config}.
+ *
+ * <p>This is currently empty, but is a class (rather than boolean) for consistency. If we ever
+ * export SSH configuration, more fields will be added here.
+ */
 public class SshdInfo {}
diff --git a/java/com/google/gerrit/extensions/common/SuggestInfo.java b/java/com/google/gerrit/extensions/common/SuggestInfo.java
index 91ca547..8c8c42f 100644
--- a/java/com/google/gerrit/extensions/common/SuggestInfo.java
+++ b/java/com/google/gerrit/extensions/common/SuggestInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+/** API response containing values from the {@code suggest} section of {@code gerrit.config}. */
 public class SuggestInfo {
+
+  /** Number of characters after which we provide suggestions for group member completion. */
   public int from;
 }
diff --git a/java/com/google/gerrit/extensions/common/UserConfigInfo.java b/java/com/google/gerrit/extensions/common/UserConfigInfo.java
index ec03dd0..3d8e851 100644
--- a/java/com/google/gerrit/extensions/common/UserConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/UserConfigInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+/** API response containing values from the {@code user} section of {@code gerrit.config}. */
 public class UserConfigInfo {
   public String anonymousCowardName;
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index 8853a30..e258134 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -18,12 +18,15 @@
 import static com.google.gerrit.extensions.common.testing.FileMetaSubject.fileMetas;
 import static com.google.gerrit.truth.ListSubject.elements;
 
+import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
 import com.google.gerrit.truth.ListSubject;
 
 public class DiffInfoSubject extends Subject {
@@ -60,4 +63,24 @@
     isNotNull();
     return check("metaB").about(fileMetas()).that(diffInfo.metaB);
   }
+
+  public ComparableSubject<IntraLineStatus> intralineStatus() {
+    isNotNull();
+    return check("intralineStatus").that(diffInfo.intralineStatus);
+  }
+
+  public IterableSubject webLinks() {
+    isNotNull();
+    return check("webLinks").that(diffInfo.webLinks);
+  }
+
+  public BooleanSubject binary() {
+    isNotNull();
+    return check("binary").that(diffInfo.binary);
+  }
+
+  public IterableSubject diffHeader() {
+    isNotNull();
+    return check("diffHeader").that(diffInfo.diffHeader);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
index fb09a1f..0953bfe 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileMetaSubject.java
@@ -18,6 +18,8 @@
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
 
@@ -42,4 +44,24 @@
     isNotNull();
     return check("totalLineCount()").that(fileMeta.lines);
   }
+
+  public StringSubject name() {
+    isNotNull();
+    return check("name").that(fileMeta.name);
+  }
+
+  public StringSubject commitId() {
+    isNotNull();
+    return check("commitId").that(fileMeta.commitId);
+  }
+
+  public StringSubject contentType() {
+    isNotNull();
+    return check("contentType").that(fileMeta.contentType);
+  }
+
+  public IterableSubject webLinks() {
+    isNotNull();
+    return check("webLinks").that(fileMeta.webLinks);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java b/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java
index 235b77f..8422b61 100644
--- a/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java
+++ b/java/com/google/gerrit/extensions/restapi/MethodNotAllowedException.java
@@ -18,10 +18,6 @@
 public class MethodNotAllowedException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
-  public MethodNotAllowedException() {
-    super();
-  }
-
   /** @param msg error text for client describing why the method is not allowed. */
   public MethodNotAllowedException(String msg) {
     super(msg);
diff --git a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index 1fa6cd0..b8c9d38 100644
--- a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -18,8 +18,6 @@
 public class PreconditionFailedException extends RestApiException {
   private static final long serialVersionUID = 1L;
 
-  public PreconditionFailedException() {}
-
   /** @param msg message to return to the client describing the error. */
   public PreconditionFailedException(String msg) {
     super(msg);
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index 5504cfd..bf363d8 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.extensions.restapi;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.gerrit.common.Nullable;
-import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
@@ -30,6 +26,12 @@
     return new Impl<>(200, value);
   }
 
+  /** HTTP 200 OK: with empty value. */
+  public static Response<String> ok() {
+    return ok("");
+  }
+
+  /** HTTP 200 OK: with forced revalidation of cache. */
   public static <T> Response<T> withMustRevalidate(T value) {
     return ok(value).caching(CacheControl.PRIVATE(0, TimeUnit.SECONDS).setMustRevalidate());
   }
@@ -39,6 +41,11 @@
     return new Impl<>(201, value);
   }
 
+  /** HTTP 201 Created: with empty value. */
+  public static Response<String> created() {
+    return created("");
+  }
+
   /** HTTP 202 Accepted: accepted as background task. */
   public static Accepted accepted(String location) {
     return new Accepted(location);
@@ -55,48 +62,24 @@
     return new Redirect(location);
   }
 
-  /**
-   * HTTP 500 Internal Server Error: failure due to an unexpected exception.
-   *
-   * <p>Can be returned from REST endpoints, instead of throwing the exception, if additional
-   * properties (e.g. a traceId) should be set on the response.
-   *
-   * @param cause the exception that caused the request to fail, must not be a {@link
-   *     RestApiException} because such an exception would result in a 4XX response code
-   */
-  public static <T> InternalServerError<T> internalServerError(Exception cause) {
-    return new InternalServerError<>(cause);
-  }
-
   /** Arbitrary status code with wrapped result. */
   public static <T> Response<T> withStatusCode(int statusCode, T value) {
     return new Impl<>(statusCode, value);
   }
 
   @SuppressWarnings({"unchecked", "rawtypes"})
-  public static <T> T unwrap(T obj) throws Exception {
+  public static <T> T unwrap(T obj) {
     while (obj instanceof Response) {
       obj = (T) ((Response) obj).value();
     }
     return obj;
   }
 
-  private String traceId;
-
-  public Response<T> traceId(@Nullable String traceId) {
-    this.traceId = traceId;
-    return this;
-  }
-
-  public Optional<String> traceId() {
-    return Optional.ofNullable(traceId);
-  }
-
   public abstract boolean isNone();
 
   public abstract int statusCode();
 
-  public abstract T value() throws Exception;
+  public abstract T value();
 
   public abstract CacheControl caching();
 
@@ -286,57 +269,4 @@
       return String.format("[202 Accepted] %s", location);
     }
   }
-
-  public static final class InternalServerError<T> extends Response<T> {
-    private final Exception cause;
-
-    private InternalServerError(Exception cause) {
-      checkArgument(!(cause instanceof RestApiException), "cause must not be a RestApiException");
-      this.cause = cause;
-    }
-
-    @Override
-    public boolean isNone() {
-      return false;
-    }
-
-    @Override
-    public int statusCode() {
-      return 500;
-    }
-
-    @Override
-    public T value() throws Exception {
-      throw cause();
-    }
-
-    @Override
-    public CacheControl caching() {
-      return CacheControl.NONE;
-    }
-
-    @Override
-    public Response<T> caching(CacheControl c) {
-      throw new UnsupportedOperationException();
-    }
-
-    public Exception cause() {
-      return cause;
-    }
-
-    @Override
-    public int hashCode() {
-      return cause.hashCode();
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return o instanceof InternalServerError && ((InternalServerError<?>) o).cause.equals(cause);
-    }
-
-    @Override
-    public String toString() {
-      return String.format("[500 Internal Server Error] %s", cause.getClass());
-    }
-  }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiException.java b/java/com/google/gerrit/extensions/restapi/RestApiException.java
index f3d7dec..de141be 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiException.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiException.java
@@ -21,13 +21,17 @@
   private static final long serialVersionUID = 1L;
   private CacheControl caching = CacheControl.NONE;
 
-  public RestApiException() {}
+  public static RestApiException wrap(String msg, Exception e) {
+    return new RestApiException(msg, e);
+  }
 
-  public RestApiException(String msg) {
+  protected RestApiException() {}
+
+  protected RestApiException(String msg) {
     super(msg);
   }
 
-  public RestApiException(String msg, Throwable cause) {
+  protected RestApiException(String msg, Throwable cause) {
     super(msg, cause);
   }
 
diff --git a/java/com/google/gerrit/extensions/validators/CommentForValidation.java b/java/com/google/gerrit/extensions/validators/CommentForValidation.java
index 51ae5ae..9b91dff 100644
--- a/java/com/google/gerrit/extensions/validators/CommentForValidation.java
+++ b/java/com/google/gerrit/extensions/validators/CommentForValidation.java
@@ -17,13 +17,21 @@
 import com.google.auto.value.AutoValue;
 
 /**
- * Holds a comment's text and {@link CommentType} in order to pass it to a validation plugin.
+ * Holds a comment's text and some metadata in order to pass it to a validation plugin.
  *
  * @see CommentValidator
  */
 @AutoValue
 public abstract class CommentForValidation {
 
+  /** The creator of the comment. */
+  public enum CommentSource {
+    /** A regular user comment. */
+    HUMAN,
+    /** A robot comment. */
+    ROBOT
+  }
+
   /** The type of comment. */
   public enum CommentType {
     /** A regular (inline) comment. */
@@ -34,14 +42,27 @@
     CHANGE_MESSAGE
   }
 
-  public static CommentForValidation create(CommentType type, String text) {
-    return new AutoValue_CommentForValidation(type, text);
+  public static CommentForValidation create(
+      CommentSource source, CommentType type, String text, long size) {
+    return new AutoValue_CommentForValidation(source, type, text, size);
   }
 
+  public abstract CommentSource getSource();
+
   public abstract CommentType getType();
 
+  /**
+   * Returns the comment text. Note that especially for robot comments the total size may be
+   * significantly larger and should be determined by using {@link #getApproximateSize()}.
+   */
   public abstract String getText();
 
+  /**
+   * Returns this instance's approximate size in bytes for the purpose of applying size limits. For
+   * robot comments this may be significantly larger than the size of the comment text.
+   */
+  public abstract long getApproximateSize();
+
   public CommentValidationFailure failValidation(String message) {
     return CommentValidationFailure.create(this, message);
   }
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
new file mode 100644
index 0000000..db08058
--- /dev/null
+++ b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.validators;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Holds a comment validators context in order to pass it to a validation plugin.
+ *
+ * <p>This is used to provided additional context around that comment that can be used by the
+ * validator to determine what validations should be run. For example, a comment validator may only
+ * want to validate a comment if it's on a change in the project foo.
+ *
+ * @see CommentValidator
+ */
+@AutoValue
+public abstract class CommentValidationContext {
+
+  /** Returns the change id the comment is being added to. */
+  public abstract int getChangeId();
+
+  /** Returns the project the comment is being added to. */
+  public abstract String getProject();
+
+  public static CommentValidationContext create(int changeId, String project) {
+    return new AutoValue_CommentValidationContext(changeId, project);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidator.java b/java/com/google/gerrit/extensions/validators/CommentValidator.java
index cfefdef..2dfd6e2 100644
--- a/java/com/google/gerrit/extensions/validators/CommentValidator.java
+++ b/java/com/google/gerrit/extensions/validators/CommentValidator.java
@@ -25,10 +25,13 @@
 public interface CommentValidator {
 
   /**
-   * Validate the specified comments.
+   * Validate the specified comments. This method will be called with all new comments that need to
+   * be validated. This allows validators to statelessly count the new comments. Note that the
+   * method may be called separately for texts that are not comments, but similar in nature and also
+   * subject to size limits, such as a change message.
    *
    * @return An empty list if all comments are valid, or else a list of validation failures.
    */
   ImmutableList<CommentValidationFailure> validateComments(
-      ImmutableList<CommentForValidation> comments);
+      CommentValidationContext ctx, ImmutableList<CommentForValidation> comments);
 }
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 519c400..2cce480 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -387,9 +388,10 @@
         toAdd.clear();
         toRemove.clear();
         break;
+      case LOCK_FAILURE:
+        throw new LockFailureException("Failed to store public keys", ru);
       case FORCED:
       case IO_FAILURE:
-      case LOCK_FAILURE:
       case NOT_ATTEMPTED:
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 5396e1c..1b5e06a 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -40,7 +40,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.gpg.CheckResult;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
@@ -59,7 +58,6 @@
 import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -207,10 +205,9 @@
       AccountResource rsrc, List<PGPPublicKeyRing> keyRings, Collection<Fingerprint> toRemove)
       throws RestApiException, PGPException, IOException {
     try {
-      retryHelper.execute(
-          ActionType.ACCOUNT_UPDATE,
-          () -> tryStoreKeys(rsrc, keyRings, toRemove),
-          LockFailureException.class::isInstance);
+      retryHelper
+          .accountUpdate("storeGpgKeys", () -> tryStoreKeys(rsrc, keyRings, toRemove))
+          .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, RestApiException.class);
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index bcb2a2a..ee99702 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -14,7 +14,6 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
@@ -38,7 +37,6 @@
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:codec",
         "//lib/commons:lang",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index 89ad878..77c5381 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -24,6 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -46,17 +47,15 @@
     if (idString.endsWith("/")) {
       idString = idString.substring(0, idString.length() - 1);
     }
-    Change.Id id;
-    try {
-      id = Change.Id.parse(idString);
-    } catch (IllegalArgumentException e) {
+    Optional<Change.Id> id = Change.Id.tryParse(idString);
+    if (!id.isPresent()) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     }
 
     ChangeResource changeResource;
     try {
-      changeResource = changesCollection.parse(id);
+      changeResource = changesCollection.parse(id.get());
     } catch (ResourceConflictException | ResourceNotFoundException e) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index 88a3f0a..d43fcc7 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -20,6 +20,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -48,7 +49,6 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
-import org.apache.commons.codec.binary.Base64;
 
 /**
  * Authenticates the current user by HTTP basic authentication.
@@ -110,7 +110,7 @@
       return true;
     }
 
-    final byte[] decoded = Base64.decodeBase64(hdr.substring(LIT_BASIC.length()));
+    final byte[] decoded = BaseEncoding.base64().decode(hdr.substring(LIT_BASIC.length()));
     String usernamePassword = new String(decoded, encoding(req));
     int splitPos = usernamePassword.indexOf(':');
     if (splitPos < 1) {
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index 8300823..dab36c4 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -53,7 +54,6 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
-import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -225,7 +225,7 @@
 
   private AuthInfo extractAuthInfo(String hdr, String encoding)
       throws UnsupportedEncodingException {
-    byte[] decoded = Base64.decodeBase64(hdr.substring(BASIC.length()));
+    byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
     String usernamePassword = new String(decoded, encoding);
     int splitPos = usernamePassword.indexOf(':');
     if (splitPos < 1 || splitPos == usernamePassword.length() - 1) {
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index a02b5a0..84954dc 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -18,8 +18,8 @@
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.BaseEncoding;
 import javax.servlet.http.HttpServletRequest;
-import org.eclipse.jgit.util.Base64;
 
 public class RemoteUserUtil {
   /**
@@ -70,7 +70,7 @@
 
     } else if (auth.startsWith("Basic ")) {
       auth = auth.substring("Basic ".length());
-      auth = new String(Base64.decode(auth), UTF_8);
+      auth = new String(BaseEncoding.base64().decode(auth), UTF_8);
       final int c = auth.indexOf(':');
       return c > 0 ? auth.substring(0, c) : null;
 
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index dd4549e..11c9295 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -16,7 +16,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
-        "//lib/commons:codec",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 84dee6e..c7b65d0 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -19,6 +19,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -45,7 +46,6 @@
 import javax.servlet.ServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @SessionScoped
@@ -244,7 +244,7 @@
   private static String generateRandomState() {
     byte[] state = new byte[32];
     randomState.nextBytes(state);
-    return Base64.encodeBase64URLSafeString(state);
+    return BaseEncoding.base64Url().encode(state);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index 94f436b..29841aa 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -17,7 +17,6 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:servlet-api",
-        "//lib/commons:codec",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index bbdb0c4..f9e6286 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -43,7 +44,6 @@
 import javax.servlet.ServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** OAuth protocol implementation */
@@ -229,7 +229,7 @@
   private static String generateRandomState() {
     byte[] state = new byte[32];
     randomState.nextBytes(state);
-    return Base64.encodeBase64URLSafeString(state);
+    return BaseEncoding.base64Url().encode(state);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 0befbd3..769396e 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -101,6 +101,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
+import com.google.gerrit.sshd.commands.SequenceCommandsModule;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
 import com.google.inject.AbstractModule;
 import com.google.inject.CreationException;
@@ -374,6 +375,7 @@
             sysInjector.getInstance(DownloadConfig.class),
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
     modules.add(new IndexCommandsModule(sysInjector));
+    modules.add(new SequenceCommandsModule());
     return sysInjector.createChildInjector(modules);
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index b9b66bc..b1d4ac6 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -130,6 +130,9 @@
     if (urlParameterMap.containsKey("sc")) {
       data.put("polyfillSC", "true");
     }
+    if (urlParameterMap.containsKey("gf")) {
+      data.put("useGoogleFonts", "true");
+    }
     return data.build();
   }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
index fc099a6..aa1b921 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.httpd.restapi;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.httpd.restapi.RestApiServlet.ViewData;
 import com.google.gerrit.metrics.Counter1;
-import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
@@ -36,7 +37,7 @@
   };
 
   final Counter1<String> count;
-  final Counter2<String, Integer> errorCount;
+  final Counter3<String, Integer, String> errorCount;
   final Timer1<String> serverLatency;
   final Histogram1<String> responseBytes;
 
@@ -59,6 +60,9 @@
             viewField,
             Field.ofInteger("error_code", Metadata.Builder::httpStatus)
                 .description("HTTP status code")
+                .build(),
+            Field.ofString("cause", Metadata.Builder::cause)
+                .description("The cause of the error.")
                 .build());
 
     serverLatency =
@@ -79,16 +83,19 @@
   }
 
   String view(ViewData viewData) {
-    String impl = viewData.view.getClass().getName().replace('$', '.');
+    return view(viewData.view.getClass(), viewData.pluginName);
+  }
+
+  String view(Class<?> clazz, @Nullable String pluginName) {
+    String impl = clazz.getName().replace('$', '.');
     for (String p : PKGS) {
       if (impl.startsWith(p)) {
         impl = impl.substring(p.length());
         break;
       }
     }
-    if (!Strings.isNullOrEmpty(viewData.pluginName)
-        && !PluginName.GERRIT.equals(viewData.pluginName)) {
-      impl = viewData.pluginName + '-' + impl;
+    if (!Strings.isNullOrEmpty(pluginName) && !PluginName.GERRIT.equals(pluginName)) {
+      impl = pluginName + '-' + impl;
     }
     return impl;
   }
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index ad2aaf8..a81a14e6 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -17,6 +17,7 @@
 
 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.flogger.LazyArgs.lazy;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
@@ -44,13 +45,13 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
-import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
@@ -96,13 +97,13 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
@@ -111,10 +112,12 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -123,7 +126,10 @@
 import com.google.gerrit.server.quota.QuotaException;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
-import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryableAction;
+import com.google.gerrit.server.update.RetryableAction.Action;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
@@ -170,6 +176,7 @@
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import java.util.zip.GZIPOutputStream;
@@ -241,6 +248,8 @@
     final Config config;
     final DynamicSet<PerformanceLogger> performanceLoggers;
     final ChangeFinder changeFinder;
+    final RetryHelper retryHelper;
+    final PluginSetContext<ExceptionHook> exceptionHooks;
 
     @Inject
     Globals(
@@ -254,7 +263,9 @@
         RestApiQuotaEnforcer quotaChecker,
         @GerritServerConfig Config config,
         DynamicSet<PerformanceLogger> performanceLoggers,
-        ChangeFinder changeFinder) {
+        ChangeFinder changeFinder,
+        RetryHelper retryHelper,
+        PluginSetContext<ExceptionHook> exceptionHooks) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -266,6 +277,8 @@
       this.config = config;
       this.performanceLoggers = performanceLoggers;
       this.changeFinder = changeFinder;
+      this.retryHelper = retryHelper;
+      this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
     }
 
@@ -280,7 +293,6 @@
 
   private final Globals globals;
   private final Provider<RestCollection<RestResource, RestResource>> members;
-  private Optional<String> traceId = Optional.empty();
 
   public RestApiServlet(
       Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
@@ -304,8 +316,9 @@
     long auditStartTs = TimeUtil.nowMs();
     res.setHeader("Content-Disposition", "attachment");
     res.setHeader("X-Content-Type-Options", "nosniff");
-    int status = SC_OK;
+    int statusCode = SC_OK;
     long responseBytes = -1;
+    Optional<Exception> cause = Optional.empty();
     Response<?> response = null;
     QueryParams qp = null;
     Object inputRequestBody = null;
@@ -326,12 +339,7 @@
         // TraceIT#performanceLoggingForRestCall()).
         try (PerformanceLogContext performanceLogContext =
             new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
-          logger.atFinest().log(
-              "Received REST request: %s %s (parameters: %s)",
-              req.getMethod(), req.getRequestURI(), getParameterNames(req));
-          logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
-          logger.atFinest().log(
-              "Groups: %s", globals.currentUser.get().getEffectiveGroups().getKnownGroups());
+          traceRequestData(req);
 
           if (isCorsPreflight(req)) {
             doCorsPreflight(req, res);
@@ -376,7 +384,7 @@
           } else {
             IdString id = path.remove(0);
             try {
-              rsrc = rc.parse(rsrc, id);
+              rsrc = parseResourceWithRetry(req, traceContext, viewData.pluginName, rc, rsrc, id);
               globals.quotaChecker.enforce(rsrc, req);
               if (path.isEmpty()) {
                 checkPreconditions(req);
@@ -449,7 +457,7 @@
             }
             IdString id = path.remove(0);
             try {
-              rsrc = c.parse(rsrc, id);
+              rsrc = parseResourceWithRetry(req, traceContext, viewData.pluginName, c, rsrc, id);
               checkPreconditions(req);
               viewData = new ViewData(null, null);
             } catch (ResourceNotFoundException e) {
@@ -484,7 +492,7 @@
             checkRequiresCapability(viewData);
           }
 
-          if (notModified(req, rsrc, viewData.view)) {
+          if (notModified(req, traceContext, viewData, rsrc)) {
             logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
             res.sendError(SC_NOT_MODIFIED);
             return;
@@ -495,7 +503,9 @@
           }
 
           if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-            response = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+            response =
+                invokeRestReadViewWithRetry(
+                    req, traceContext, viewData, (RestReadView<RestResource>) viewData.view, rsrc);
           } else if (viewData.view instanceof RestModifyView<?, ?>) {
             @SuppressWarnings("unchecked")
             RestModifyView<RestResource, Object> m =
@@ -503,7 +513,10 @@
 
             Type type = inputType(m);
             inputRequestBody = parseRequest(req, type);
-            response = m.apply(rsrc, inputRequestBody);
+            response =
+                invokeRestModifyViewWithRetry(
+                    req, traceContext, viewData, m, rsrc, inputRequestBody);
+
             if (inputRequestBody instanceof RawInput) {
               try (InputStream is = req.getInputStream()) {
                 ServletUtils.consumeRequestBody(is);
@@ -516,7 +529,9 @@
 
             Type type = inputType(m);
             inputRequestBody = parseRequest(req, type);
-            response = m.apply(rsrc, path.get(0), inputRequestBody);
+            response =
+                invokeRestCollectionCreateViewWithRetry(
+                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
             if (inputRequestBody instanceof RawInput) {
               try (InputStream is = req.getInputStream()) {
                 ServletUtils.consumeRequestBody(is);
@@ -529,7 +544,9 @@
 
             Type type = inputType(m);
             inputRequestBody = parseRequest(req, type);
-            response = m.apply(rsrc, path.get(0), inputRequestBody);
+            response =
+                invokeRestCollectionDeleteMissingViewWithRetry(
+                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
             if (inputRequestBody instanceof RawInput) {
               try (InputStream is = req.getInputStream()) {
                 ServletUtils.consumeRequestBody(is);
@@ -542,7 +559,9 @@
 
             Type type = inputType(m);
             inputRequestBody = parseRequest(req, type);
-            response = m.apply(rsrc, inputRequestBody);
+            response =
+                invokeRestCollectionModifyViewWithRetry(
+                    req, traceContext, viewData, m, rsrc, inputRequestBody);
             if (inputRequestBody instanceof RawInput) {
               try (InputStream is = req.getInputStream()) {
                 ServletUtils.consumeRequestBody(is);
@@ -552,9 +571,6 @@
             throw new ResourceNotFoundException();
           }
 
-          traceId = response.traceId();
-          traceId.ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
-
           if (response instanceof Response.Redirect) {
             CacheHeaders.setNotCacheable(res);
             String location = ((Response.Redirect) response).location();
@@ -567,18 +583,12 @@
             res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
             logger.atFinest().log("REST call succeeded: %d", response.statusCode());
             return;
-          } else if (response instanceof Response.InternalServerError) {
-            // Rethrow the exception to have exactly the same error handling as if the REST endpoint
-            // would have thrown the exception directly, instead of returning
-            // Response.InternalServerError.
-            Exception cause = ((Response.InternalServerError<?>) response).cause();
-            throw cause;
           }
 
-          status = response.statusCode();
-          configureCaching(req, res, rsrc, viewData.view, response.caching());
-          res.setStatus(status);
-          logger.atFinest().log("REST call succeeded: %d", status);
+          statusCode = response.statusCode();
+          configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+          res.setStatus(statusCode);
+          logger.atFinest().log("REST call succeeded: %d", statusCode);
         }
 
         if (response != Response.none()) {
@@ -590,82 +600,112 @@
           }
         }
       } catch (MalformedJsonException | JsonParseException e) {
+        cause = Optional.of(e);
         responseBytes =
             replyError(
-                req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
+                req, res, statusCode = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
       } catch (BadRequestException e) {
+        cause = Optional.of(e);
         responseBytes =
             replyError(
-                req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
+                req, res, statusCode = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e);
       } catch (AuthException e) {
+        cause = Optional.of(e);
         responseBytes =
-            replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
+            replyError(
+                req, res, statusCode = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e);
       } catch (AmbiguousViewException e) {
-        responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
-      } catch (ResourceNotFoundException e) {
+        cause = Optional.of(e);
         responseBytes =
-            replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
+            replyError(req, res, statusCode = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e);
+      } catch (ResourceNotFoundException e) {
+        cause = Optional.of(e);
+        responseBytes =
+            replyError(
+                req, res, statusCode = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e);
       } catch (MethodNotAllowedException e) {
+        cause = Optional.of(e);
         responseBytes =
             replyError(
                 req,
                 res,
-                status = SC_METHOD_NOT_ALLOWED,
+                statusCode = SC_METHOD_NOT_ALLOWED,
                 messageOr(e, "Method Not Allowed"),
                 e.caching(),
                 e);
       } catch (ResourceConflictException e) {
+        cause = Optional.of(e);
         responseBytes =
-            replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
+            replyError(
+                req, res, statusCode = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e);
       } catch (PreconditionFailedException e) {
+        cause = Optional.of(e);
         responseBytes =
             replyError(
                 req,
                 res,
-                status = SC_PRECONDITION_FAILED,
+                statusCode = SC_PRECONDITION_FAILED,
                 messageOr(e, "Precondition Failed"),
                 e.caching(),
                 e);
       } catch (UnprocessableEntityException e) {
+        cause = Optional.of(e);
         responseBytes =
             replyError(
                 req,
                 res,
-                status = SC_UNPROCESSABLE_ENTITY,
+                statusCode = SC_UNPROCESSABLE_ENTITY,
                 messageOr(e, "Unprocessable Entity"),
                 e.caching(),
                 e);
       } catch (NotImplementedException e) {
+        cause = Optional.of(e);
         logger.atSevere().withCause(e).log("Error in %s %s", req.getMethod(), uriForLogging(req));
         responseBytes =
-            replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
-      } catch (UpdateException e) {
-        Throwable t = e.getCause();
-        if (t instanceof LockFailureException) {
-          logger.atSevere().withCause(t).log("Error in %s %s", req.getMethod(), uriForLogging(req));
-          responseBytes = replyError(req, res, status = SC_SERVICE_UNAVAILABLE, "Lock failure", e);
-        } else {
-          status = SC_INTERNAL_SERVER_ERROR;
-          responseBytes = handleException(e, req, res);
-        }
+            replyError(
+                req, res, statusCode = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e);
       } catch (QuotaException e) {
+        cause = Optional.of(e);
         responseBytes =
             replyError(
                 req,
                 res,
-                status = SC_TOO_MANY_REQUESTS,
+                statusCode = SC_TOO_MANY_REQUESTS,
                 messageOr(e, "Quota limit reached"),
                 e.caching(),
                 e);
       } catch (Exception e) {
-        status = SC_INTERNAL_SERVER_ERROR;
-        responseBytes = handleException(e, req, res);
+        cause = Optional.of(e);
+        statusCode = SC_INTERNAL_SERVER_ERROR;
+
+        Optional<ExceptionHook.Status> status = getStatus(e);
+        statusCode = status.map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
+
+        if (res.isCommitted()) {
+          responseBytes = 0;
+          if (statusCode == SC_INTERNAL_SERVER_ERROR) {
+            logger.atSevere().withCause(e).log(
+                "Error in %s %s, response already committed", req.getMethod(), uriForLogging(req));
+          } else {
+            logger.atWarning().log(
+                "Response for %s %s already committed, wanted to set status %d",
+                req.getMethod(), uriForLogging(req), statusCode);
+          }
+        } else {
+          res.reset();
+          traceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+
+          if (status.isPresent()) {
+            responseBytes = reply(req, res, e, status.get(), getUserMessages(traceContext, e));
+          }
+          responseBytes = replyInternalServerError(req, res, e, getUserMessages(traceContext, e));
+        }
       } finally {
-        String metric =
-            viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
+        String metric = getViewName(viewData);
+        String formattedCause = cause.map(globals.retryHelper::formatCause).orElse("_none");
         globals.metrics.count.increment(metric);
-        if (status >= SC_BAD_REQUEST) {
-          globals.metrics.errorCount.increment(metric, status);
+        if (statusCode >= SC_BAD_REQUEST) {
+          globals.metrics.errorCount.increment(metric, statusCode, formattedCause);
         }
         if (responseBytes != -1) {
           globals.metrics.responseBytes.record(metric, responseBytes);
@@ -680,7 +720,7 @@
                 auditStartTs,
                 qp != null ? qp.params() : ImmutableListMultimap.of(),
                 inputRequestBody,
-                status,
+                statusCode,
                 response,
                 rsrc,
                 viewData == null ? null : viewData.view));
@@ -688,6 +728,180 @@
     }
   }
 
+  private String getEtagWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      ViewData viewData,
+      ETagView<RestResource> view,
+      RestResource rsrc) {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "RestApiServlet#getEtagWithRetry:view",
+            Metadata.builder().restViewName(getViewName(viewData)).build())) {
+      return invokeRestEndpointWithRetry(
+          req,
+          traceContext,
+          getViewName(viewData) + "#etag",
+          ActionType.REST_READ_REQUEST,
+          () -> view.getETag(rsrc));
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new IllegalStateException("Failed to get ETag for view", e);
+    }
+  }
+
+  private String getEtagWithRetry(
+      HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "RestApiServlet#getEtagWithRetry:resource",
+            Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
+      return invokeRestEndpointWithRetry(
+          req,
+          traceContext,
+          rsrc.getClass().getSimpleName() + "#etag",
+          ActionType.REST_READ_REQUEST,
+          () -> rsrc.getETag());
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new IllegalStateException("Failed to get ETag for resource", e);
+    }
+  }
+
+  private RestResource parseResourceWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      @Nullable String pluginName,
+      RestCollection<RestResource, RestResource> restCollection,
+      RestResource parentResource,
+      IdString id)
+      throws Exception {
+    return invokeRestEndpointWithRetry(
+        req,
+        traceContext,
+        globals.metrics.view(restCollection.getClass(), pluginName) + "#parse",
+        ActionType.REST_READ_REQUEST,
+        () -> restCollection.parse(parentResource, id));
+  }
+
+  private Response<?> invokeRestReadViewWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      ViewData viewData,
+      RestReadView<RestResource> view,
+      RestResource rsrc)
+      throws Exception {
+    return invokeRestEndpointWithRetry(
+        req,
+        traceContext,
+        getViewName(viewData),
+        ActionType.REST_READ_REQUEST,
+        () -> view.apply(rsrc));
+  }
+
+  private Response<?> invokeRestModifyViewWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      ViewData viewData,
+      RestModifyView<RestResource, Object> view,
+      RestResource rsrc,
+      Object inputRequestBody)
+      throws Exception {
+    return invokeRestEndpointWithRetry(
+        req,
+        traceContext,
+        getViewName(viewData),
+        ActionType.REST_WRITE_REQUEST,
+        () -> view.apply(rsrc, inputRequestBody));
+  }
+
+  private Response<?> invokeRestCollectionCreateViewWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      ViewData viewData,
+      RestCollectionCreateView<RestResource, RestResource, Object> view,
+      RestResource rsrc,
+      IdString path,
+      Object inputRequestBody)
+      throws Exception {
+    return invokeRestEndpointWithRetry(
+        req,
+        traceContext,
+        getViewName(viewData),
+        ActionType.REST_WRITE_REQUEST,
+        () -> view.apply(rsrc, path, inputRequestBody));
+  }
+
+  private Response<?> invokeRestCollectionDeleteMissingViewWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      ViewData viewData,
+      RestCollectionDeleteMissingView<RestResource, RestResource, Object> view,
+      RestResource rsrc,
+      IdString path,
+      Object inputRequestBody)
+      throws Exception {
+    return invokeRestEndpointWithRetry(
+        req,
+        traceContext,
+        getViewName(viewData),
+        ActionType.REST_WRITE_REQUEST,
+        () -> view.apply(rsrc, path, inputRequestBody));
+  }
+
+  private Response<?> invokeRestCollectionModifyViewWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      ViewData viewData,
+      RestCollectionModifyView<RestResource, RestResource, Object> view,
+      RestResource rsrc,
+      Object inputRequestBody)
+      throws Exception {
+    return invokeRestEndpointWithRetry(
+        req,
+        traceContext,
+        getViewName(viewData),
+        ActionType.REST_WRITE_REQUEST,
+        () -> view.apply(rsrc, inputRequestBody));
+  }
+
+  private <T> T invokeRestEndpointWithRetry(
+      HttpServletRequest req,
+      TraceContext traceContext,
+      String caller,
+      ActionType actionType,
+      Action<T> action)
+      throws Exception {
+    RetryableAction<T> retryableAction = globals.retryHelper.action(actionType, caller, action);
+    AtomicReference<Optional<String>> traceId = new AtomicReference<>(Optional.empty());
+    if (!traceContext.isTracing()) {
+      // enable automatic retry with tracing in case of non-recoverable failure
+      retryableAction
+          .retryWithTrace(t -> !(t instanceof RestApiException))
+          .onAutoTrace(
+              autoTraceId -> {
+                traceId.set(Optional.of(autoTraceId));
+
+                // Include details of the request into the trace.
+                traceRequestData(req);
+              });
+    }
+    try {
+      return retryableAction.call();
+    } finally {
+      // If auto-tracing got triggered due to a non-recoverable failure, also trace the rest of
+      // this request. This means logging is forced for all further log statements and the logs are
+      // associated with the same trace ID.
+      traceId
+          .get()
+          .ifPresent(tid -> traceContext.addTag(RequestId.Type.TRACE_ID, tid).forceLogging());
+    }
+  }
+
+  private String getViewName(ViewData viewData) {
+    return viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown";
+  }
+
   private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
       throws BadRequestException {
     if (!isPost(req)) {
@@ -805,24 +1019,27 @@
     return defaultMessage;
   }
 
-  @SuppressWarnings({"unchecked", "rawtypes"})
-  private static boolean notModified(
-      HttpServletRequest req, RestResource rsrc, RestView<RestResource> view) {
+  private boolean notModified(
+      HttpServletRequest req, TraceContext traceContext, ViewData viewData, RestResource rsrc) {
     if (!isRead(req)) {
       return false;
     }
 
+    RestView<RestResource> view = viewData.view;
     if (view instanceof ETagView) {
       String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
       if (have != null) {
-        return have.equals(((ETagView) view).getETag(rsrc));
+        String eTag =
+            getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
+        return have.equals(eTag);
       }
     }
 
     if (rsrc instanceof RestResource.HasETag) {
       String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
       if (have != null) {
-        return have.equals(((RestResource.HasETag) rsrc).getETag());
+        String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
+        return have.equals(eTag);
       }
     }
 
@@ -836,21 +1053,48 @@
     return false;
   }
 
-  private static <R extends RestResource> void configureCaching(
-      HttpServletRequest req, HttpServletResponse res, R rsrc, RestView<R> view, CacheControl c) {
+  private <R extends RestResource> void configureCaching(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      TraceContext traceContext,
+      R rsrc,
+      ViewData viewData,
+      CacheControl cacheControl) {
+    setCacheHeaders(req, res, cacheControl);
     if (isRead(req)) {
-      switch (c.getType()) {
+      switch (cacheControl.getType()) {
+        case NONE:
+        default:
+          break;
+        case PRIVATE:
+          addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+          break;
+        case PUBLIC:
+          addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+          break;
+      }
+    }
+  }
+
+  private static <R extends RestResource> void setCacheHeaders(
+      HttpServletRequest req, HttpServletResponse res, CacheControl cacheControl) {
+    if (isRead(req)) {
+      switch (cacheControl.getType()) {
         case NONE:
         default:
           CacheHeaders.setNotCacheable(res);
           break;
         case PRIVATE:
-          addResourceStateHeaders(res, rsrc, view);
-          CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit(), c.isMustRevalidate());
+          CacheHeaders.setCacheablePrivate(
+              res, cacheControl.getAge(), cacheControl.getUnit(), cacheControl.isMustRevalidate());
           break;
         case PUBLIC:
-          addResourceStateHeaders(res, rsrc, view);
-          CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit(), c.isMustRevalidate());
+          CacheHeaders.setCacheable(
+              req,
+              res,
+              cacheControl.getAge(),
+              cacheControl.getUnit(),
+              cacheControl.isMustRevalidate());
           break;
       }
     } else {
@@ -858,12 +1102,20 @@
     }
   }
 
-  private static <R extends RestResource> void addResourceStateHeaders(
-      HttpServletResponse res, R rsrc, RestView<R> view) {
+  private void addResourceStateHeaders(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      TraceContext traceContext,
+      ViewData viewData,
+      RestResource rsrc) {
+    RestView<RestResource> view = viewData.view;
     if (view instanceof ETagView) {
-      res.setHeader(HttpHeaders.ETAG, ((ETagView<R>) view).getETag(rsrc));
+      String eTag =
+          getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
+      res.setHeader(HttpHeaders.ETAG, eTag);
     } else if (rsrc instanceof RestResource.HasETag) {
-      res.setHeader(HttpHeaders.ETAG, ((RestResource.HasETag) rsrc).getETag());
+      String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
+      res.setHeader(HttpHeaders.ETAG, eTag);
     }
     if (rsrc instanceof RestResource.HasLastModified) {
       res.setDateHeader(
@@ -937,8 +1189,14 @@
           }
           return OutputFormat.JSON.newGson().fromJson(json, type);
         } finally {
-          // Reader.close won't consume the rest of the input. Explicitly consume the request body.
-          br.skip(Long.MAX_VALUE);
+          try {
+            // Reader.close won't consume the rest of the input. Explicitly consume the request
+            // body.
+            br.skip(Long.MAX_VALUE);
+          } catch (Exception e) {
+            // ignore, e.g. trying to consume the rest of the input may fail if the request was
+            // cancelled
+          }
         }
       }
     }
@@ -999,7 +1257,7 @@
         return obj;
       }
     }
-    throw new MethodNotAllowedException();
+    throw new MethodNotAllowedException("raw input not supported");
   }
 
   private Object parseString(String value, Type type)
@@ -1451,6 +1709,15 @@
     return requestInfo.build();
   }
 
+  private void traceRequestData(HttpServletRequest req) {
+    logger.atFinest().log(
+        "Received REST request: %s %s (parameters: %s)",
+        req.getMethod(), req.getRequestURI(), getParameterNames(req));
+    logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
+    logger.atFinest().log(
+        "Groups: %s", globals.currentUser.get().getEffectiveGroups().getKnownGroups());
+  }
+
   private boolean isDelete(HttpServletRequest req) {
     return "DELETE".equals(req.getMethod());
   }
@@ -1493,15 +1760,62 @@
     }
   }
 
-  private long handleException(Throwable err, HttpServletRequest req, HttpServletResponse res)
+  private Optional<ExceptionHook.Status> getStatus(Throwable err) {
+    return globals.exceptionHooks.stream()
+        .map(h -> h.getStatus(err))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .findFirst();
+  }
+
+  private ImmutableList<String> getUserMessages(TraceContext traceContext, Throwable err) {
+    return globals.exceptionHooks.stream()
+        .flatMap(h -> h.getUserMessages(err, traceContext.getTraceId().orElse(null)).stream())
+        .collect(toImmutableList());
+  }
+
+  private static long reply(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      Throwable err,
+      ExceptionHook.Status status,
+      ImmutableList<String> userMessages)
       throws IOException {
-    logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uriForLogging(req));
-    if (!res.isCommitted()) {
-      res.reset();
-      traceId.ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
-      return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err);
+    res.setStatus(status.statusCode());
+
+    StringBuilder msg = new StringBuilder(status.statusMessage());
+    if (!userMessages.isEmpty()) {
+      msg.append("\n");
+      userMessages.forEach(m -> msg.append("\n* ").append(m));
     }
-    return 0;
+
+    if (status.statusCode() < SC_BAD_REQUEST) {
+      logger.atFinest().withCause(err).log("REST call finished: %d", status.statusCode());
+      return replyText(req, res, true, msg.toString());
+    }
+    if (status.statusCode() >= SC_INTERNAL_SERVER_ERROR) {
+      logger.atSevere().withCause(err).log("Error in %s %s", req.getMethod(), uriForLogging(req));
+    }
+    return replyError(req, res, status.statusCode(), msg.toString(), err);
+  }
+
+  private long replyInternalServerError(
+      HttpServletRequest req,
+      HttpServletResponse res,
+      Throwable err,
+      ImmutableList<String> userMessages)
+      throws IOException {
+    logger.atSevere().withCause(err).log(
+        "Error in %s %s: %s",
+        req.getMethod(), uriForLogging(req), globals.retryHelper.formatCause(err));
+
+    StringBuilder msg = new StringBuilder("Internal server error");
+    if (!userMessages.isEmpty()) {
+      msg.append("\n");
+      userMessages.forEach(m -> msg.append("\n* ").append(m));
+    }
+
+    return replyError(req, res, SC_INTERNAL_SERVER_ERROR, msg.toString(), err);
   }
 
   private static String uriForLogging(HttpServletRequest req) {
@@ -1527,13 +1841,13 @@
       HttpServletResponse res,
       int statusCode,
       String msg,
-      CacheControl c,
+      CacheControl cacheControl,
       @Nullable Throwable err)
       throws IOException {
     if (err != null) {
       RequestUtil.setErrorTraceAttribute(req, err);
     }
-    configureCaching(req, res, null, null, c);
+    setCacheHeaders(req, res, cacheControl);
     checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
     res.setStatus(statusCode);
     logger.atFinest().withCause(err).log("REST call failed: %d", statusCode);
diff --git a/java/com/google/gerrit/index/IndexCollection.java b/java/com/google/gerrit/index/IndexCollection.java
index 0615453..c61e6c7 100644
--- a/java/com/google/gerrit/index/IndexCollection.java
+++ b/java/com/google/gerrit/index/IndexCollection.java
@@ -32,7 +32,7 @@
     this.searchIndex = new AtomicReference<>();
   }
 
-  /** @return the current search index version. */
+  /** Returns the current search index version. */
   public I getSearchIndex() {
     return searchIndex.get();
   }
diff --git a/java/com/google/gerrit/index/IndexRewriter.java b/java/com/google/gerrit/index/IndexRewriter.java
index 4d6a35b..7f3bc2d 100644
--- a/java/com/google/gerrit/index/IndexRewriter.java
+++ b/java/com/google/gerrit/index/IndexRewriter.java
@@ -17,7 +17,15 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 
+/**
+ * Rewriter to sanitize queries before they are sent to the index. The idea here is that the
+ * rewriter swaps out predicates so that the query can be processed by the index.
+ */
 public interface IndexRewriter<T> {
 
+  /**
+   * Returns a sanitized version of the provided predicate. Uses {@link QueryOptions} to enforce
+   * index-specific limits such as {@code maxTerms}.
+   */
   Predicate<T> rewrite(Predicate<T> in, QueryOptions opts) throws QueryParseException;
 }
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index f9f8c48..0aa374b 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.base.MoreObjects;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -181,12 +182,17 @@
    * <p>Null values are omitted, as are fields which cause errors, which are logged.
    *
    * @param obj input object.
+   * @param skipFields set of field names to skip when indexing the document
    * @return all non-null field values from the object.
    */
-  public final Iterable<Values<T>> buildFields(T obj) {
-    return FluentIterable.from(fields.values())
-        .transform(
+  public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
+    return fields.values().stream()
+        .map(
             f -> {
+              if (skipFields.contains(f.getName())) {
+                return null;
+              }
+
               Object v;
               try {
                 v = f.get(obj);
@@ -203,7 +209,8 @@
                 return new Values<>(f, Collections.singleton(v));
               }
             })
-        .filter(Objects::nonNull);
+        .filter(Objects::nonNull)
+        .collect(toImmutableList());
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index c3ab8a4..ecfc7bd 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Stopwatch;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -31,36 +32,28 @@
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.util.io.NullOutputStream;
 
+/** Base class for implementations that can index all entities of a given type. */
 public abstract class SiteIndexer<K, V, I extends Index<K, V>> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Result {
-    private final long elapsedNanos;
-    private final boolean success;
-    private final int done;
-    private final int failed;
+  /** Result of an operation to index a subset or all of the entities of a given type. */
+  @AutoValue
+  public abstract static class Result {
+    public abstract long elapsedNanos();
 
-    public Result(Stopwatch sw, boolean success, int done, int failed) {
-      this.elapsedNanos = sw.elapsed(TimeUnit.NANOSECONDS);
-      this.success = success;
-      this.done = done;
-      this.failed = failed;
-    }
+    public abstract boolean success();
 
-    public boolean success() {
-      return success;
-    }
+    public abstract int doneCount();
 
-    public int doneCount() {
-      return done;
-    }
+    public abstract int failedCount();
 
-    public int failedCount() {
-      return failed;
+    public static Result create(Stopwatch sw, boolean success, int done, int failed) {
+      return new AutoValue_SiteIndexer_Result(
+          sw.elapsed(TimeUnit.NANOSECONDS), success, done, failed);
     }
 
     public long elapsed(TimeUnit timeUnit) {
-      return timeUnit.convert(elapsedNanos, TimeUnit.NANOSECONDS);
+      return timeUnit.convert(elapsedNanos(), TimeUnit.NANOSECONDS);
     }
   }
 
@@ -80,6 +73,7 @@
     verboseWriter = newPrintWriter(requireNonNull(out));
   }
 
+  /** Indexes all entities for the provided index. */
   public abstract Result indexAll(I index);
 
   protected final void addErrorListener(
diff --git a/java/com/google/gerrit/index/project/IndexedProjectQuery.java b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
index cdfeabd..5fc74ca 100644
--- a/java/com/google/gerrit/index/project/IndexedProjectQuery.java
+++ b/java/com/google/gerrit/index/project/IndexedProjectQuery.java
@@ -22,6 +22,10 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 
+/**
+ * Wrapper around {@link Predicate}s that are returned by the {@link
+ * com.google.gerrit.index.IndexRewriter}. See {@link IndexedQuery}.
+ */
 public class IndexedProjectQuery extends IndexedQuery<Project.NameKey, ProjectData>
     implements DataSource<ProjectData> {
 
diff --git a/java/com/google/gerrit/index/project/ProjectData.java b/java/com/google/gerrit/index/project/ProjectData.java
index 2bf9a4b5..c70b823 100644
--- a/java/com/google/gerrit/index/project/ProjectData.java
+++ b/java/com/google/gerrit/index/project/ProjectData.java
@@ -23,6 +23,11 @@
 import java.util.List;
 import java.util.Optional;
 
+/**
+ * Representation of a Gerrit project in the project index.
+ *
+ * <p>Includes information about all parent projects.
+ */
 public class ProjectData {
   private final Project project;
   private final Optional<ProjectData> parent;
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index 3e99d55..b2ddaff 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -19,6 +19,10 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 
+/**
+ * Index for Gerrit projects (repositories). This class is mainly used for typing the generic parent
+ * class that contains actual implementations.
+ */
 public interface ProjectIndex extends Index<Project.NameKey, ProjectData> {
 
   public interface Factory
diff --git a/java/com/google/gerrit/index/project/ProjectIndexCollection.java b/java/com/google/gerrit/index/project/ProjectIndexCollection.java
index 30227a3..7ff23c5 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexCollection.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexCollection.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.index.IndexCollection;
 import com.google.inject.Singleton;
 
+/** Collection of active project indices. See {@link IndexCollection} for details on collections. */
 @Singleton
 public class ProjectIndexCollection
     extends IndexCollection<Project.NameKey, ProjectData, ProjectIndex> {
diff --git a/java/com/google/gerrit/index/project/ProjectIndexRewriter.java b/java/com/google/gerrit/index/project/ProjectIndexRewriter.java
index 9e2bbdc..230161b 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexRewriter.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexRewriter.java
@@ -23,6 +23,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/** Rewriter for the project index. See {@link IndexRewriter} for details. */
 @Singleton
 public class ProjectIndexRewriter implements IndexRewriter<ProjectData> {
   private final ProjectIndexCollection indexes;
diff --git a/java/com/google/gerrit/index/project/ProjectIndexer.java b/java/com/google/gerrit/index/project/ProjectIndexer.java
index bd5efeb2..34e8075 100644
--- a/java/com/google/gerrit/index/project/ProjectIndexer.java
+++ b/java/com/google/gerrit/index/project/ProjectIndexer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Project;
 
+/** Interface for indexing a Gerrit project. */
 public interface ProjectIndexer {
 
   /**
diff --git a/java/com/google/gerrit/index/project/ProjectPredicate.java b/java/com/google/gerrit/index/project/ProjectPredicate.java
index 4926eef..11875ef 100644
--- a/java/com/google/gerrit/index/project/ProjectPredicate.java
+++ b/java/com/google/gerrit/index/project/ProjectPredicate.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
 
+/** Predicate that is mapped to a field in the project index. */
 public class ProjectPredicate extends IndexPredicate<ProjectData> {
   public ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
     super(def, value);
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index f355216..3cc5f9b 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
+/** Definition of project index versions (schemata). See {@link SchemaDefinitions}. */
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
   @Deprecated
@@ -39,10 +40,14 @@
   // Lucene index was changed to add an additional field for sorting.
   static final Schema<ProjectData> V4 = schema(V3);
 
-  public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
-
+  /**
+   * Name of the project index to be used when contacting index backends or loading configurations.
+   */
   public static final String NAME = "projects";
 
+  /** Singleton instance of the schema definitions. This is one per JVM. */
+  public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
+
   private ProjectSchemaDefinitions() {
     super(NAME, ProjectData.class);
   }
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 7811a32..aac6682 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 
-/** Index-aware predicate that includes a field type annotation. */
+/** Predicate that is mapped to a field in the index. */
 public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
   private final FieldDef<I, ?> def;
 
diff --git a/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index d24cfeb..85dcf3e 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -29,6 +29,7 @@
 
 import com.google.common.base.Ascii;
 import com.google.common.base.CharMatcher;
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
@@ -42,7 +43,9 @@
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import org.antlr.runtime.tree.Tree;
 
 /**
@@ -184,6 +187,7 @@
 
   protected final Definition<T, Q> builderDef;
   private final ImmutableMap<String, OperatorFactory<T, Q>> opFactories;
+  protected Map<String, String> opAliases = Collections.emptyMap();
 
   protected QueryBuilder(
       Definition<T, Q> def,
@@ -220,6 +224,10 @@
     return toPredicate(QueryParser.parse(query));
   }
 
+  public void setOperatorAliases(Map<String, String> opAliases) {
+    this.opAliases = opAliases;
+  }
+
   /**
    * Parse multiple user-supplied query strings into a list of predicates.
    *
@@ -290,8 +298,12 @@
 
   @SuppressWarnings("unchecked")
   private Predicate<T> operator(String name, String value) throws QueryParseException {
+    String opName = MoreObjects.firstNonNull(opAliases.get(name), name);
     @SuppressWarnings("rawtypes")
-    OperatorFactory f = opFactories.get(name);
+    OperatorFactory f = opFactories.get(opName);
+    if (f == null && !opName.equals(name)) {
+      f = opFactories.get(name);
+    }
     if (f == null) {
       throw error("Unsupported operator " + name + ":" + value);
     }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 9501e52..c05516b 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -18,7 +18,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.flogger.LazyArgs.lazy;
-import static java.util.stream.Collectors.toSet;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -272,7 +272,7 @@
         ImmutableList<T> matchesList = matches.get(i).toList();
         logger.atFine().log(
             "Matches[%d]:\n%s",
-            i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toSet())));
+            i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toList())));
         out.add(
             QueryResult.create(
                 queryStrings != null ? queryStrings.get(i) : null,
diff --git a/java/com/google/gerrit/index/query/RangeUtil.java b/java/com/google/gerrit/index/query/RangeUtil.java
index 1f22f36..cfe1929 100644
--- a/java/com/google/gerrit/index/query/RangeUtil.java
+++ b/java/com/google/gerrit/index/query/RangeUtil.java
@@ -106,6 +106,10 @@
         break;
     }
 
+    // Ensure that minValue <= min/max <= maxValue.
+    min = Ints.constrainToRange(min, minValue, maxValue);
+    max = Ints.constrainToRange(max, minValue, maxValue);
+
     return new Range(prefix, min, max);
   }
 
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index deb3203..5392ab4 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -98,6 +99,7 @@
   private final SitePaths sitePaths;
   private final Directory dir;
   private final String name;
+  private final ImmutableSet<String> skipFields;
   private final ListeningExecutorService writerThread;
   private final IndexWriter writer;
   private final ReferenceManager<IndexSearcher> searcherManager;
@@ -110,6 +112,7 @@
       SitePaths sitePaths,
       Directory dir,
       String name,
+      ImmutableSet<String> skipFields,
       String subIndex,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory)
@@ -118,6 +121,7 @@
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
+    this.skipFields = skipFields;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -311,7 +315,7 @@
 
   Document toDocument(V obj) {
     Document result = new Document();
-    for (Values<V> vs : schema.buildFields(obj)) {
+    for (Values<V> vs : schema.buildFields(obj, skipFields)) {
       if (vs.getValues() != null) {
         add(result, vs);
       }
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index fd439f1..e51a91a7 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -48,6 +49,7 @@
       Schema<ChangeData> schema,
       SitePaths sitePaths,
       Path path,
+      ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory)
       throws IOException {
@@ -56,6 +58,7 @@
         sitePaths,
         FSDirectory.open(path),
         path.getFileName().toString(),
+        skipFields,
         writerConfig,
         searcherFactory);
   }
@@ -65,10 +68,11 @@
       SitePaths sitePaths,
       Directory dir,
       String subIndex,
+      ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory)
       throws IOException {
-    super(schema, sitePaths, dir, NAME, subIndex, writerConfig, searcherFactory);
+    super(schema, sitePaths, dir, NAME, skipFields, subIndex, writerConfig, searcherFactory);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index efd7ea3..242cffd 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.index.account.AccountField.ID_STR;
 import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
@@ -100,6 +101,7 @@
         sitePaths,
         dir(schema, cfg, sitePaths),
         ACCOUNTS,
+        ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, ACCOUNTS),
         new SearcherFactory());
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 16d66b6..375967b 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -54,6 +55,7 @@
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexExecutor;
@@ -167,6 +169,7 @@
   private final String idSortFieldName;
   private final IdTerm idTerm;
   private final ChangeIdExtractor extractor;
+  private final ImmutableSet<String> skipFields;
 
   @Inject
   LuceneChangeIndex(
@@ -179,6 +182,10 @@
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.schema = schema;
+    this.skipFields =
+        MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()
+            ? ImmutableSet.of()
+            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
 
     GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
     GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
@@ -189,18 +196,40 @@
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       openIndex =
           new ChangeSubIndex(
-              schema, sitePaths, new RAMDirectory(), "ramOpen", openConfig, searcherFactory);
+              schema,
+              sitePaths,
+              new RAMDirectory(),
+              "ramOpen",
+              skipFields,
+              openConfig,
+              searcherFactory);
       closedIndex =
           new ChangeSubIndex(
-              schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
+              schema,
+              sitePaths,
+              new RAMDirectory(),
+              "ramClosed",
+              skipFields,
+              closedConfig,
+              searcherFactory);
     } else {
       Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
       openIndex =
           new ChangeSubIndex(
-              schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
+              schema,
+              sitePaths,
+              dir.resolve(CHANGES_OPEN),
+              skipFields,
+              openConfig,
+              searcherFactory);
       closedIndex =
           new ChangeSubIndex(
-              schema, sitePaths, dir.resolve(CHANGES_CLOSED), closedConfig, searcherFactory);
+              schema,
+              sitePaths,
+              dir.resolve(CHANGES_CLOSED),
+              skipFields,
+              closedConfig,
+              searcherFactory);
     }
 
     idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
@@ -565,7 +594,7 @@
 
   private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
-    if (f != null) {
+    if (f != null && !skipFields.contains(MERGEABLE_FIELD)) {
       String mergeable = f.stringValue();
       if ("1".equals(mergeable)) {
         cd.setMergeable(true);
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 99cd40d..3d1d471 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.server.index.group.GroupField.UUID;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
@@ -90,6 +91,7 @@
         sitePaths,
         dir(schema, cfg, sitePaths),
         GROUPS,
+        ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, GROUPS),
         new SearcherFactory());
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 97454c7..a3a0d9c 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.index.project.ProjectField.NAME;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
@@ -90,6 +91,7 @@
         sitePaths,
         dir(schema, cfg, sitePaths),
         PROJECTS,
+        ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, PROJECTS),
         new SearcherFactory());
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 2457b6a..2e9ef2f 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -111,6 +111,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
+import com.google.gerrit.sshd.commands.SequenceCommandsModule;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
@@ -519,6 +520,7 @@
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
     if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
+      modules.add(new SequenceCommandsModule());
     }
     return sysInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 22bc21d..096e4a1 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -36,8 +36,10 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import javax.servlet.DispatcherType;
@@ -411,10 +413,20 @@
         Class<? extends Filter> filterClass =
             (Class<? extends Filter>) Class.forName(filterClassName);
         Filter filter = env.webInjector.getInstance(filterClass);
-        app.addFilter(
-            new FilterHolder(filter),
-            "/*",
-            EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
+
+        Map<String, String> initParams = new HashMap<>();
+        Set<String> initParamKeys = cfg.getNames("filterClass", filterClassName, true);
+        initParamKeys.forEach(
+            paramKey -> {
+              String paramValue = cfg.getString("filterClass", filterClassName, paramKey);
+              initParams.put(paramKey, paramValue);
+            });
+
+        FilterHolder filterHolder = new FilterHolder(filter);
+        if (initParams.size() > 0) {
+          filterHolder.setInitParameters(initParams);
+        }
+        app.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
       } catch (Throwable e) {
         throw new IllegalArgumentException(
             "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
diff --git a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index 9354209..4f9d7e7 100644
--- a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -34,6 +34,10 @@
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import javax.servlet.AsyncContext;
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import javax.servlet.DispatcherType;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -43,16 +47,13 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jetty.continuation.Continuation;
-import org.eclipse.jetty.continuation.ContinuationListener;
-import org.eclipse.jetty.continuation.ContinuationSupport;
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Use Jetty continuations to defer execution until threads are available.
+ * Use AsyncContexts to defer execution until threads are available.
  *
  * <p>We actually schedule a task into the same execution queue as the SSH daemon uses for command
- * execution, and then park the web request in a continuation until an execution thread is
+ * execution, and then park the web request in an AsyncContext until an execution thread is
  * available. This ensures that the overall JVM process doesn't exceed the configured limit on
  * concurrent Git requests.
  *
@@ -61,12 +62,10 @@
  * Jetty's HTTP parser to crash, so we instead block the SSH execution queue thread and ask Jetty to
  * resume processing on the web service thread.
  */
-@SuppressWarnings("deprecation")
 @Singleton
 public class ProjectQoSFilter implements Filter {
-  private static final String ATT_SPACE = ProjectQoSFilter.class.getName();
-  private static final String TASK = ATT_SPACE + "/TASK";
-  private static final String CANCEL = ATT_SPACE + "/CANCEL";
+  private static final String ATT_SPACE = ProjectQoSFilter.class.getName() + "/";
+  private static final String TASK = ATT_SPACE + "TASK";
 
   private static final String FILTER_RE = "^/(.*)/(git-upload-pack|git-receive-pack)$";
   private static final Pattern URI_PATTERN = Pattern.compile(FILTER_RE);
@@ -79,6 +78,59 @@
     }
   }
 
+  public enum RequestState {
+    INITIAL,
+    SUSPENDED,
+    RESUMED,
+    CANCELED,
+    UNEXPECTED;
+
+    private static final String CANCELED_ATT = ATT_SPACE + CANCELED;
+    private static final String SUSPENDED_ATT = ATT_SPACE + SUSPENDED;
+    private static final String RESUMED_ATT = ATT_SPACE + RESUMED;
+
+    private void set(ServletRequest req) {
+      switch (this) {
+        case SUSPENDED:
+          req.setAttribute(SUSPENDED_ATT, true);
+          req.setAttribute(RESUMED_ATT, false);
+          break;
+        case CANCELED:
+          req.setAttribute(CANCELED_ATT, true);
+          break;
+        case RESUMED:
+          req.setAttribute(RESUMED_ATT, true);
+          break;
+        case INITIAL:
+        case UNEXPECTED:
+        default:
+          break;
+      }
+    }
+
+    private static RequestState get(ServletRequest req) {
+      if (Boolean.FALSE.equals(req.getAttribute(RESUMED_ATT))
+          && Boolean.TRUE.equals(req.getAttribute(SUSPENDED_ATT))) {
+        return SUSPENDED;
+      }
+
+      if (req.getDispatcherType() != DispatcherType.ASYNC) {
+        return INITIAL;
+      }
+
+      if (Boolean.TRUE.equals(req.getAttribute(RESUMED_ATT))
+          && Boolean.TRUE.equals(req.getAttribute(CANCELED_ATT))) {
+        return CANCELED;
+      }
+
+      if (Boolean.TRUE.equals(req.getAttribute(RESUMED_ATT))) {
+        return RESUMED;
+      }
+
+      return UNEXPECTED;
+    }
+  }
+
   private final AccountLimits.Factory limitsFactory;
   private final Provider<CurrentUser> user;
   private final QueueProvider queue;
@@ -104,40 +156,50 @@
       throws IOException, ServletException {
     final HttpServletRequest req = (HttpServletRequest) request;
     final HttpServletResponse rsp = (HttpServletResponse) response;
-    final Continuation cont = ContinuationSupport.getContinuation(req);
 
-    if (cont.isInitial()) {
-      TaskThunk task = new TaskThunk(cont, req);
-      if (maxWait > 0) {
-        cont.setTimeout(maxWait);
-      }
-      cont.suspend(rsp);
-      cont.setAttribute(TASK, task);
+    final TaskThunk task;
 
-      Future<?> f = getExecutor().submit(task);
-      cont.addContinuationListener(new Listener(f));
-    } else if (cont.isExpired()) {
-      rsp.sendError(SC_SERVICE_UNAVAILABLE);
+    switch (RequestState.get(request)) {
+      case INITIAL:
+        AsyncContext asyncContext = suspend(request);
+        task = new TaskThunk(asyncContext, req);
+        if (maxWait > 0) {
+          asyncContext.setTimeout(maxWait);
+        }
 
-    } else if (cont.isResumed() && cont.getAttribute(CANCEL) == Boolean.TRUE) {
-      rsp.sendError(SC_SERVICE_UNAVAILABLE);
+        request.setAttribute(TASK, task);
 
-    } else if (cont.isResumed()) {
-      TaskThunk task = (TaskThunk) cont.getAttribute(TASK);
-      try {
-        task.begin(Thread.currentThread());
-        chain.doFilter(req, rsp);
-      } finally {
-        task.end();
-        Thread.interrupted();
-      }
-
-    } else {
-      context.log("Unexpected QoS continuation state, aborting request");
-      rsp.sendError(SC_SERVICE_UNAVAILABLE);
+        Future<?> f = getExecutor().submit(task);
+        asyncContext.addListener(new Listener(f));
+        break;
+      case CANCELED:
+        rsp.sendError(SC_SERVICE_UNAVAILABLE);
+        break;
+      case RESUMED:
+        task = (TaskThunk) request.getAttribute(TASK);
+        try {
+          task.begin(Thread.currentThread());
+          chain.doFilter(req, rsp);
+        } finally {
+          task.end();
+          Thread.interrupted();
+        }
+        break;
+      case SUSPENDED:
+      case UNEXPECTED:
+      default:
+        context.log("Unexpected QoS state, aborting request");
+        rsp.sendError(SC_SERVICE_UNAVAILABLE);
+        break;
     }
   }
 
+  private AsyncContext suspend(ServletRequest request) {
+    AsyncContext asyncContext = request.startAsync();
+    RequestState.SUSPENDED.set(request);
+    return asyncContext;
+  }
+
   private ScheduledThreadPoolExecutor getExecutor() {
     QueueProvider.QueueType qt = limitsFactory.create(user.get()).getQueueType();
     return queue.getQueue(qt);
@@ -149,7 +211,7 @@
   @Override
   public void destroy() {}
 
-  private static final class Listener implements ContinuationListener {
+  private static final class Listener implements AsyncListener {
     final Future<?> future;
 
     Listener(Future<?> future) {
@@ -157,29 +219,35 @@
     }
 
     @Override
-    public void onComplete(Continuation self) {}
+    public void onComplete(AsyncEvent event) throws IOException {}
 
     @Override
-    public void onTimeout(Continuation self) {
+    public void onTimeout(AsyncEvent event) throws IOException {
       future.cancel(true);
     }
+
+    @Override
+    public void onError(AsyncEvent event) throws IOException {}
+
+    @Override
+    public void onStartAsync(AsyncEvent event) throws IOException {}
   }
 
   private final class TaskThunk implements CancelableRunnable {
-    private final Continuation cont;
+    private final AsyncContext asyncContext;
     private final String name;
     private final Object lock = new Object();
     private boolean done;
     private Thread worker;
 
-    TaskThunk(Continuation cont, HttpServletRequest req) {
-      this.cont = cont;
+    TaskThunk(AsyncContext asyncContext, HttpServletRequest req) {
+      this.asyncContext = asyncContext;
       this.name = generateName(req);
     }
 
     @Override
     public void run() {
-      cont.resume();
+      resume();
 
       synchronized (lock) {
         while (!done) {
@@ -212,8 +280,16 @@
 
     @Override
     public void cancel() {
-      cont.setAttribute(CANCEL, Boolean.TRUE);
-      cont.resume();
+      RequestState.CANCELED.set(asyncContext.getRequest());
+      resume();
+    }
+
+    private void resume() {
+      ServletRequest req = asyncContext.getRequest();
+      if (RequestState.SUSPENDED.equals(RequestState.get(req))) {
+        RequestState.RESUMED.set(req);
+        asyncContext.dispatch();
+      }
     }
 
     @Override
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 7c1241a..0a15fda 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -7,5 +7,7 @@
     deps = [
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
     ],
 )
diff --git a/java/com/google/gerrit/prettify/common/SparseFileContent.java b/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 348f9b2..1249b65 100644
--- a/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -14,160 +14,175 @@
 
 package com.google.gerrit.prettify.common;
 
-import java.util.ArrayList;
-import java.util.List;
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 
-public class SparseFileContent {
-  protected List<Range> ranges;
-  protected int size;
+/**
+ * A class to store subset of a file's lines in a memory efficient way. Internally, it stores lines
+ * as a list of ranges. Each range represents continuous set of lines and has information about line
+ * numbers in original file (zero-based).
+ *
+ * <p>{@link SparseFileContent.Accessor} must be used to work with the stored content.
+ */
+@AutoValue
+public abstract class SparseFileContent {
+  abstract ImmutableList<Range> getRanges();
 
-  private transient int currentRangeIdx;
+  public abstract int getSize();
 
-  public SparseFileContent() {
-    ranges = new ArrayList<>();
+  public static SparseFileContent create(ImmutableList<Range> ranges, int size) {
+    return new AutoValue_SparseFileContent(ranges, size);
   }
 
-  public int size() {
-    return size;
+  @VisibleForTesting
+  public int getRangesCount() {
+    return getRanges().size();
   }
 
-  public void setSize(int s) {
-    size = s;
+  public Accessor createAccessor() {
+    return new Accessor(this);
   }
 
-  public String get(int idx) {
-    final String line = getLine(idx);
-    if (line == null) {
-      throw new ArrayIndexOutOfBoundsException(idx);
-    }
-    return line;
-  }
+  /**
+   * Provide a methods to work with the content of a {@link SparseFileContent}.
+   *
+   * <p>The class hides internal representation of a {@link SparseFileContent} and provides
+   * convenient way for accessing a content.
+   */
+  public static class Accessor {
+    private final SparseFileContent content;
+    private int currentRangeIdx;
 
-  public boolean contains(int idx) {
-    return getLine(idx) != null;
-  }
-
-  public int first() {
-    return ranges.isEmpty() ? size() : ranges.get(0).base;
-  }
-
-  public int next(int idx) {
-    // Most requests are sequential in nature, fetching the next
-    // line from the current range, or the immediate next range.
-    //
-    int high = ranges.size();
-    if (currentRangeIdx < high) {
-      Range cur = ranges.get(currentRangeIdx);
-      if (cur.contains(idx + 1)) {
-        return idx + 1;
-      }
-
-      if (++currentRangeIdx < high) {
-        // Its not plus one, its the base of the next range.
-        //
-        return ranges.get(currentRangeIdx).base;
-      }
+    private Accessor(SparseFileContent content) {
+      this.content = content;
     }
 
-    // Binary search for the current value, since we know its a sorted list.
-    //
-    int low = 0;
-    do {
-      final int mid = (low + high) / 2;
-      final Range cur = ranges.get(mid);
+    public String get(int idx) {
+      final String line = getLine(idx);
+      if (line == null) {
+        throw new ArrayIndexOutOfBoundsException(idx);
+      }
+      return line;
+    }
 
-      if (cur.contains(idx)) {
+    public int getSize() {
+      return content.getSize();
+    }
+
+    public boolean contains(int idx) {
+      return getLine(idx) != null;
+    }
+
+    public int first() {
+      return content.getRanges().isEmpty() ? getSize() : content.getRanges().get(0).getBase();
+    }
+
+    public int next(int idx) {
+      // Most requests are sequential in nature, fetching the next
+      // line from the current range, or the immediate next range.
+      //
+      ImmutableList<Range> ranges = content.getRanges();
+      int high = ranges.size();
+      if (currentRangeIdx < high) {
+        Range cur = ranges.get(currentRangeIdx);
         if (cur.contains(idx + 1)) {
-          // Trivial plus one case above failed due to wrong currentRangeIdx.
-          // Reset the cache so we don't miss in the future.
-          //
-          currentRangeIdx = mid;
           return idx + 1;
         }
 
-        if (mid + 1 < ranges.size()) {
-          // Its the base of the next range.
-          currentRangeIdx = mid + 1;
-          return ranges.get(currentRangeIdx).base;
-        }
-
-        // No more lines in the file.
-        //
-        return size();
-      }
-
-      if (idx < cur.base) {
-        high = mid;
-      } else {
-        low = mid + 1;
-      }
-    } while (low < high);
-
-    return size();
-  }
-
-  private String getLine(int idx) {
-    // Most requests are sequential in nature, fetching the next
-    // line from the current range, or the next range.
-    //
-    int high = ranges.size();
-    if (currentRangeIdx < high) {
-      Range cur = ranges.get(currentRangeIdx);
-      if (cur.contains(idx)) {
-        return cur.get(idx);
-      }
-
-      if (++currentRangeIdx < high) {
-        final Range next = ranges.get(currentRangeIdx);
-        if (next.contains(idx)) {
-          return next.get(idx);
+        if (++currentRangeIdx < high) {
+          // Its not plus one, its the base of the next range.
+          //
+          return ranges.get(currentRangeIdx).getBase();
         }
       }
+
+      // Binary search for the current value, since we know its a sorted list.
+      //
+      int low = 0;
+      do {
+        final int mid = (low + high) / 2;
+        final Range cur = ranges.get(mid);
+
+        if (cur.contains(idx)) {
+          if (cur.contains(idx + 1)) {
+            // Trivial plus one case above failed due to wrong currentRangeIdx.
+            // Reset the cache so we don't miss in the future.
+            //
+            currentRangeIdx = mid;
+            return idx + 1;
+          }
+
+          if (mid + 1 < ranges.size()) {
+            // Its the base of the next range.
+            currentRangeIdx = mid + 1;
+            return ranges.get(currentRangeIdx).getBase();
+          }
+
+          // No more lines in the file.
+          //
+          return getSize();
+        }
+
+        if (idx < cur.getBase()) {
+          high = mid;
+        } else {
+          low = mid + 1;
+        }
+      } while (low < high);
+
+      return getSize();
     }
 
-    // Binary search for the range, since we know its a sorted list.
-    //
-    if (ranges.isEmpty()) {
+    private String getLine(int idx) {
+      // Most requests are sequential in nature, fetching the next
+      // line from the current range, or the next range.
+      //
+      ImmutableList<Range> ranges = content.getRanges();
+      int high = ranges.size();
+      if (currentRangeIdx < high) {
+        Range cur = ranges.get(currentRangeIdx);
+        if (cur.contains(idx)) {
+          return cur.get(idx);
+        }
+
+        if (++currentRangeIdx < high) {
+          final Range next = ranges.get(currentRangeIdx);
+          if (next.contains(idx)) {
+            return next.get(idx);
+          }
+        }
+      }
+
+      // Binary search for the range, since we know its a sorted list.
+      //
+      if (ranges.isEmpty()) {
+        return null;
+      }
+
+      int low = 0;
+      do {
+        final int mid = (low + high) / 2;
+        final Range cur = ranges.get(mid);
+        if (cur.contains(idx)) {
+          currentRangeIdx = mid;
+          return cur.get(idx);
+        }
+        if (idx < cur.getBase()) {
+          high = mid;
+        } else {
+          low = mid + 1;
+        }
+      } while (low < high);
       return null;
     }
-
-    int low = 0;
-    do {
-      final int mid = (low + high) / 2;
-      final Range cur = ranges.get(mid);
-      if (cur.contains(idx)) {
-        currentRangeIdx = mid;
-        return cur.get(idx);
-      }
-      if (idx < cur.base) {
-        high = mid;
-      } else {
-        low = mid + 1;
-      }
-    } while (low < high);
-    return null;
-  }
-
-  public void addLine(int i, String content) {
-    final Range r;
-    if (!ranges.isEmpty() && i == last().end()) {
-      r = last();
-    } else {
-      r = new Range(i);
-      ranges.add(r);
-    }
-    r.lines.add(content);
-  }
-
-  private Range last() {
-    return ranges.get(ranges.size() - 1);
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     final StringBuilder b = new StringBuilder();
     b.append("SparseFileContent[\n");
-    for (Range r : ranges) {
+    for (Range r : getRanges()) {
       b.append("  ");
       b.append(r.toString());
       b.append('\n');
@@ -176,33 +191,32 @@
     return b.toString();
   }
 
-  static class Range {
-    protected int base;
-    protected List<String> lines;
-
-    private Range(int b) {
-      base = b;
-      lines = new ArrayList<>();
+  @AutoValue
+  abstract static class Range {
+    static Range create(int base, ImmutableList<String> lines) {
+      return new AutoValue_SparseFileContent_Range(base, lines);
     }
 
-    protected Range() {}
+    abstract int getBase();
+
+    abstract ImmutableList<String> getLines();
 
     private String get(int i) {
-      return lines.get(i - base);
+      return getLines().get(i - getBase());
     }
 
     private int end() {
-      return base + lines.size();
+      return getBase() + getLines().size();
     }
 
     private boolean contains(int i) {
-      return base <= i && i < end();
+      return getBase() <= i && i < end();
     }
 
     @Override
-    public String toString() {
+    public final String toString() {
       // Usage of [ and ) is intentional to denote inclusive/exclusive range
-      return "Range[" + base + "," + end() + ")";
+      return "Range[" + getBase() + "," + end() + ")";
     }
   }
 }
diff --git a/java/com/google/gerrit/prettify/common/SparseFileContentBuilder.java b/java/com/google/gerrit/prettify/common/SparseFileContentBuilder.java
new file mode 100644
index 0000000..04fb5d1
--- /dev/null
+++ b/java/com/google/gerrit/prettify/common/SparseFileContentBuilder.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.prettify.common;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.prettify.common.SparseFileContent.Range;
+
+/**
+ * A builder for creating immutable {@link SparseFileContent}. Lines can be only be added in
+ * sequential (increased) order
+ */
+public class SparseFileContentBuilder {
+  private final ImmutableList.Builder<Range> ranges;
+  private final int size;
+  private int lastRangeBase;
+  private int lastRangeEnd;
+  private ImmutableList.Builder<String> lastRangeLines;
+
+  public SparseFileContentBuilder(int size) {
+    ranges = new ImmutableList.Builder<>();
+    startNextRange(0);
+    this.size = size;
+  }
+
+  public void addLine(int lineNumber, String content) {
+    if (lineNumber < 0) {
+      throw new IllegalArgumentException("Line number must be non-negative");
+    }
+    //    if (lineNumber >= size) {
+    //     The following 4 tests are failed if you uncomment this condition:
+    //
+    //
+    // diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileAndWithCommentReturnsFileContents
+    //
+    // diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileAndWithCommentReturnsFileContents
+    //
+    //
+    // diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileAndWithCommentReturnsFileContents
+    //
+    // diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileAndWithCommentReturnsFileContents
+    //     Tests are failed because there are some bug with diff calculation.
+    //     The condition must be uncommented after all these bugs are fixed.
+    //     Also don't forget to remove ignore from for SparseFileContentBuilder
+    //      throw new IllegalArgumentException(String.format("The zero-based line number %d is after
+    // the end of file. The file size is %d line(s).", lineNumber, size));
+    //    }
+    if (lineNumber < lastRangeEnd) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Invalid line number %d. You are trying to add a line before an already added line"
+                  + " %d",
+              lineNumber, lastRangeEnd));
+    }
+    if (lineNumber > lastRangeEnd) {
+      finishLastRange();
+      startNextRange(lineNumber);
+    }
+    lastRangeLines.add(content);
+    lastRangeEnd++;
+  }
+
+  private void startNextRange(int base) {
+    lastRangeLines = new ImmutableList.Builder<>();
+    lastRangeBase = lastRangeEnd = base;
+  }
+
+  private void finishLastRange() {
+    if (lastRangeEnd > lastRangeBase) {
+      ranges.add(Range.create(lastRangeBase, lastRangeLines.build()));
+      lastRangeLines = null;
+    }
+  }
+
+  public SparseFileContent build() {
+    finishLastRange();
+    return SparseFileContent.create(ranges.build(), size);
+  }
+}
diff --git a/java/com/google/gerrit/prettify/common/testing/BUILD b/java/com/google/gerrit/prettify/common/testing/BUILD
new file mode 100644
index 0000000..5057fdb
--- /dev/null
+++ b/java/com/google/gerrit/prettify/common/testing/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_testonly = True)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/prettify:server",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/prettify/common/testing/SparseFileContentSubject.java b/java/com/google/gerrit/prettify/common/testing/SparseFileContentSubject.java
new file mode 100644
index 0000000..c1fe1ec
--- /dev/null
+++ b/java/com/google/gerrit/prettify/common/testing/SparseFileContentSubject.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.prettify.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.MapSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import java.util.HashMap;
+import java.util.Map;
+
+public class SparseFileContentSubject extends Subject {
+  public static SparseFileContentSubject assertThat(SparseFileContent sparseFileContent) {
+    return assertAbout(sparseFileContent()).that(sparseFileContent);
+  }
+
+  private final SparseFileContent sparseFileContent;
+
+  private SparseFileContentSubject(FailureMetadata metadata, SparseFileContent actual) {
+    super(metadata, actual);
+    this.sparseFileContent = actual;
+  }
+
+  private static Subject.Factory<SparseFileContentSubject, SparseFileContent> sparseFileContent() {
+    return SparseFileContentSubject::new;
+  }
+
+  public IntegerSubject getSize() {
+    isNotNull();
+    return check("size()").that(sparseFileContent.getSize());
+  }
+
+  public IntegerSubject getRangesCount() {
+    isNotNull();
+    return check("rangesCount()").that(sparseFileContent.getRangesCount());
+  }
+
+  public MapSubject lines() {
+    isNotNull();
+    Map<Integer, String> lines = new HashMap<>();
+    SparseFileContent.Accessor accessor = sparseFileContent.createAccessor();
+    int size = accessor.getSize();
+    int idx = accessor.first();
+    while (idx < size) {
+      lines.put(idx, accessor.get(idx));
+      idx = accessor.next(idx);
+    }
+    return check("lines()").that(lines);
+  }
+}
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 6675595..45d037a 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -54,6 +54,7 @@
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/git",
@@ -106,12 +107,12 @@
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
-        "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
         "//lib/commons:net",
         "//lib/commons:validator",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index ee82a26..a166d97 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -25,7 +25,6 @@
 import java.io.IOException;
 import java.security.SecureRandom;
 import java.util.Collection;
-import java.util.Map;
 import java.util.Random;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -50,22 +49,6 @@
   }
 
   /**
-   * Get the next patch set ID from a previously-read map of all refs.
-   *
-   * @param allRefs map of full ref name to ref.
-   * @param id previous patch set ID.
-   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
-   *     names appear in the {@code allRefs} map.
-   */
-  public static PatchSet.Id nextPatchSetIdFromAllRefsMap(Map<String, Ref> allRefs, PatchSet.Id id) {
-    PatchSet.Id next = nextPatchSetId(id);
-    while (allRefs.containsKey(next.toRefName())) {
-      next = nextPatchSetId(next);
-    }
-    return next;
-  }
-
-  /**
    * Get the next patch set ID from a previously-read map of refs below the change prefix.
    *
    * @param changeRefNames existing full change ref names with the same change ID as {@code id}.
@@ -95,9 +78,7 @@
   /**
    * Get the next patch set ID just looking at a single previous patch set ID.
    *
-   * <p>This patch set ID may or may not be available in the database; callers that want a
-   * previously-unused ID should use {@link #nextPatchSetIdFromAllRefsMap} or {@link
-   * #nextPatchSetIdFromChangeRefs}.
+   * <p>This patch set ID may or may not be available in the database.
    *
    * @param id previous patch set ID.
    * @return next patch set ID for the same change, incrementing by 1.
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 996257c..5e7919f 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -115,11 +115,12 @@
                 .collect(toList()));
         config.replace(createGroupAccessSection);
       } else {
-        Permission createGroupPermission = new Permission(Permission.CREATE);
-        createGroupAccessSection.addPermission(createGroupPermission);
-        createGroupsGlobal.forEach(createGroupPermission::add);
         // The create permission is managed by Gerrit at this point only so there is no concern of
         // overwriting user-defined permissions here.
+        Permission createGroupPermission = new Permission(Permission.CREATE);
+        createGroupAccessSection.remove(createGroupPermission);
+        createGroupAccessSection.addPermission(createGroupPermission);
+        createGroupsGlobal.forEach(createGroupPermission::add);
         config.replace(createGroupAccessSection);
       }
 
diff --git a/java/com/google/gerrit/server/ExceptionHook.java b/java/com/google/gerrit/server/ExceptionHook.java
index a0d98d2..89a0ab7 100644
--- a/java/com/google/gerrit/server/ExceptionHook.java
+++ b/java/com/google/gerrit/server/ExceptionHook.java
@@ -14,7 +14,13 @@
 
 package com.google.gerrit.server;
 
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Optional;
 
 /**
  * Allows implementors to control how certain exceptions should be handled.
@@ -33,10 +39,124 @@
    * <p>Only affects operations that are executed with {@link
    * com.google.gerrit.server.update.RetryHelper}.
    *
+   * <p>Should return {@code true} only for exceptions that are caused by temporary issues where a
+   * retry of the operation has a chance to succeed.
+   *
+   * <p>If {@code false} is returned the operation is still retried once to capture a trace, unless
+   * {@link #skipRetryWithTrace(String, String, Throwable)} skips the auto-retry.
+   *
+   * <p>If multiple exception hooks are registered, the operation is retried if any of them returns
+   * {@code true} from this method.
+   *
    * @param throwable throwable that was thrown while executing the operation
+   * @param actionType the type of the action for which the exception occurred
+   * @param actionName the name of the action for which the exception occurred
    * @return whether the operation should be retried
    */
-  default boolean shouldRetry(Throwable throwable) {
+  default boolean shouldRetry(String actionType, String actionName, Throwable throwable) {
     return false;
   }
+
+  /**
+   * Whether auto-retrying of an operation with tracing should be skipped for the given throwable.
+   *
+   * <p>Only affects operations that are executed with {@link
+   * com.google.gerrit.server.update.RetryHelper}.
+   *
+   * <p>This method is only called for exceptions for which the operation should not be retried
+   * ({@link #shouldRetry(String, String, Throwable)} returned {@code false}).
+   *
+   * <p>By default this method returns {@code false}, so that by default traces for unexpected
+   * exceptions are captured, which allows to investigate them.
+   *
+   * <p>Implementors may use this method to skip retry with tracing for exceptions that occur due to
+   * known causes that are permanent and where a trace is not needed for the investigation. For
+   * example, if an operation fails because persisted data is corrupt, it makes no sense to retry
+   * the operation with a trace, because the trace will not help with fixing the corrupt data.
+   *
+   * <p>This method is only invoked if retry with tracing is enabled on the server ({@code
+   * retry.retryWithTraceOnFailure} in {@code gerrit.config} is set to {@code true}).
+   *
+   * <p>If multiple exception hooks are registered, retrying with tracing is skipped if any of them
+   * returns {@code true} from this method.
+   *
+   * @param throwable throwable that was thrown while executing the operation
+   * @param actionType the type of the action for which the exception occurred
+   * @param actionName the name of the action for which the exception occurred
+   * @return whether auto-retrying of an operation with tracing should be skipped for the given
+   *     throwable
+   */
+  default boolean skipRetryWithTrace(String actionType, String actionName, Throwable throwable) {
+    return false;
+  }
+
+  /**
+   * Formats the cause of an exception for use in metrics.
+   *
+   * <p>This method allows implementors to group exceptions that have the same cause into one metric
+   * bucket.
+   *
+   * <p>If multiple exception hooks return a value from this method, the value from the exception
+   * hook that is registered first is used.
+   *
+   * @param throwable the exception cause
+   * @return formatted cause or {@link Optional#empty()} if no formatting was done
+   */
+  default Optional<String> formatCause(Throwable throwable) {
+    return Optional.empty();
+  }
+
+  /**
+   * Returns messages that should be returned to the user.
+   *
+   * <p>These messages are included into the HTTP response that is sent to the user.
+   *
+   * <p>If multiple exception hooks return a value from this method, all the values are included
+   * into the HTTP response (in the order in which the exception hooks are registered).
+   *
+   * @param throwable throwable that was thrown while executing an operation
+   * @param traceId ID of the trace if this request was traced, otherwise {@code null}
+   * @return error messages that should be returned to the user, {@link Optional#empty()} if no
+   *     message should be returned to the user
+   */
+  default ImmutableList<String> getUserMessages(Throwable throwable, @Nullable String traceId) {
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns the HTTP status that should be returned to the user.
+   *
+   * <p>Implementors may use this method to change the status for certain exceptions (e.g. using
+   * this method it would be possible to return {@code 503 Lock failure} for {@link
+   * com.google.gerrit.git.LockFailureException}s instead of {@code 500 Internal server error}).
+   *
+   * <p>If no value is returned ({@link Optional#empty()}) it means that this exception hook doesn't
+   * want to change the default response code for the given exception which is {@code 500 Internal
+   * Server Error}, but is fine if other exception hook implementation do so.
+   *
+   * <p>If multiple exception hooks return a value from this method, the value from exception hook
+   * that is registered first is used.
+   *
+   * <p>{@link #getUserMessages(Throwable, String)} allows to define which additional messages
+   * should be included into the body of the HTTP response.
+   *
+   * @param throwable throwable that was thrown while executing an operation
+   * @return HTTP status that should be returned to the user, {@link Optional#empty()} if the
+   *     exception should result in {@code 500 Internal Server Error}
+   */
+  default Optional<Status> getStatus(Throwable throwable) {
+    return Optional.empty();
+  }
+
+  @AutoValue
+  public abstract class Status {
+    public abstract int statusCode();
+
+    public abstract String statusMessage();
+
+    public static Status create(int statusCode, String statusMessage) {
+      return new AutoValue_ExceptionHook_Status(
+          statusCode, requireNonNull(statusMessage, "statusMessage"));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
new file mode 100644
index 0000000..5a3f077
--- /dev/null
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Predicate;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.LockFailureException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.RefUpdate;
+
+/**
+ * Class to detect and handle exceptions that are caused by temporary errors, and hence should cause
+ * a retry of the failed operation.
+ */
+public class ExceptionHookImpl implements ExceptionHook {
+  private static final String LOCK_FAILURE_USER_MESSAGE =
+      "Updating a ref failed with LOCK_FAILURE.\n"
+          + "This may be a temporary issue due to concurrent updates.\n"
+          + "Please retry later.";
+
+  @Override
+  public boolean shouldRetry(String actionType, String actionName, Throwable throwable) {
+    return isLockFailure(throwable);
+  }
+
+  @Override
+  public Optional<String> formatCause(Throwable throwable) {
+    if (isLockFailure(throwable)) {
+      return Optional.of(RefUpdate.Result.LOCK_FAILURE.name());
+    }
+    if (isMissingObjectException(throwable)) {
+      return Optional.of("missing_object");
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public ImmutableList<String> getUserMessages(Throwable throwable, @Nullable String traceId) {
+    if (isLockFailure(throwable)) {
+      return ImmutableList.of(LOCK_FAILURE_USER_MESSAGE);
+    }
+    return ImmutableList.of();
+  }
+
+  @Override
+  public Optional<Status> getStatus(Throwable throwable) {
+    if (isLockFailure(throwable)) {
+      return Optional.of(Status.create(503, "Lock failure"));
+    }
+    return Optional.empty();
+  }
+
+  private static boolean isLockFailure(Throwable throwable) {
+    return isMatching(throwable, t -> t instanceof LockFailureException);
+  }
+
+  private static boolean isMissingObjectException(Throwable throwable) {
+    return isMatching(throwable, t -> t instanceof MissingObjectException);
+  }
+
+  /**
+   * Check whether the given exception or any of its causes matches the given predicate.
+   *
+   * @param throwable Exception that should be tested
+   * @param predicate predicate to check if a throwable matches
+   * @return {@code true} if the given exception or any of its causes matches the given predicate
+   */
+  private static boolean isMatching(Throwable throwable, Predicate<Throwable> predicate) {
+    return Throwables.getCausalChain(throwable).stream().anyMatch(predicate);
+  }
+}
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 26539c5..3d34d6b 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -15,18 +15,21 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Comment.Status;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -34,10 +37,14 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 
 @Singleton
 public class PublishCommentUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final CommentsUtil commentsUtil;
@@ -52,7 +59,7 @@
 
   public void publish(
       ChangeContext ctx,
-      PatchSet.Id psId,
+      ChangeUpdate changeUpdate,
       Collection<Comment> draftComments,
       @Nullable String tag) {
     ChangeNotes notes = ctx.getNotes();
@@ -63,11 +70,32 @@
 
     Map<PatchSet.Id, PatchSet> patchSets =
         psUtil.getAsMap(notes, draftComments.stream().map(d -> psId(notes, d)).collect(toSet()));
+    Set<Comment> commentsToPublish = new HashSet<>();
     for (Comment draftComment : draftComments) {
       PatchSet.Id psIdOfDraftComment = psId(notes, draftComment);
       PatchSet ps = patchSets.get(psIdOfDraftComment);
       if (ps == null) {
-        throw new StorageException("patch set " + psIdOfDraftComment + " not found");
+        // This can happen if changes with the same numeric ID exist:
+        // - change 12345 has 3 patch sets in repo X
+        // - another change 12345 has 7 patch sets in repo Y
+        // - the user saves a draft comment on patch set 6 of the change in repo Y
+        // - this draft comment gets stored in:
+        //   AllUsers -> refs/draft-comments/45/12345/<account-id>
+        // - when posting a review with draft handling PUBLISH_ALL_REVISIONS on the change in
+        //   repo X, the draft comments are loaded from
+        //   AllUsers -> refs/draft-comments/45/12345/<account-id>, including the draft
+        //   comment that was saved for patch set 6 of the change in repo Y
+        // - patch set 6 does not exist for the change in repo x, hence we get null for the patch
+        //   set here
+        // Instead of failing hard (and returning an Internal Server Error) to the caller,
+        // just ignore that comment.
+        // Gerrit ensures that numeric change IDs are unique, but you can get duplicates if
+        // change refs of one repo are copied/pushed to another repo on the same host (this
+        // should never be done, but we know it happens).
+        logger.atWarning().log(
+            "Ignoring draft comment %s on non existing patch set %s (repo = %s)",
+            draftComment, psIdOfDraftComment, notes.getProjectName());
+        continue;
       }
       draftComment.writtenOn = ctx.getWhen();
       draftComment.tag = tag;
@@ -79,8 +107,9 @@
       } catch (PatchListNotAvailableException e) {
         throw new StorageException(e);
       }
+      commentsToPublish.add(draftComment);
     }
-    commentsUtil.putComments(ctx.getUpdate(psId), Status.PUBLISHED, draftComments);
+    commentsUtil.putComments(changeUpdate, Status.PUBLISHED, commentsToPublish);
   }
 
   private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
@@ -90,16 +119,18 @@
   /**
    * Helper to run the specified set of {@link CommentValidator}-s on the specified comments.
    *
-   * @return See {@link CommentValidator#validateComments(ImmutableList)}.
+   * @return See {@link CommentValidator#validateComments(CommentValidationContext,ImmutableList)}.
    */
   public static ImmutableList<CommentValidationFailure> findInvalidComments(
+      CommentValidationContext ctx,
       PluginSetContext<CommentValidator> commentValidators,
       ImmutableList<CommentForValidation> commentsForValidation) {
     ImmutableList.Builder<CommentValidationFailure> commentValidationFailures =
         new ImmutableList.Builder<>();
     commentValidators.runEach(
-        listener ->
-            commentValidationFailures.addAll(listener.validateComments(commentsForValidation)));
+        validator ->
+            commentValidationFailures.addAll(
+                validator.validateComments(ctx, commentsForValidation)));
     return commentValidationFailures.build();
   }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
new file mode 100644
index 0000000..df57629
--- /dev/null
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -0,0 +1,145 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A {@link BatchUpdateOp} that can be used to publish draft comments
+ *
+ * <p>This class uses the {@link PublishCommentUtil} to publish draft comments and fires the
+ * necessary event for this.
+ */
+public class PublishCommentsOp implements BatchUpdateOp {
+  private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentAdded commentAdded;
+  private final CommentsUtil commentsUtil;
+  private final EmailReviewComments.Factory email;
+  private final List<LabelVote> labelDelta = new ArrayList<>();
+  private final Project.NameKey projectNameKey;
+  private final PatchSet.Id psId;
+  private final PublishCommentUtil publishCommentUtil;
+
+  private List<Comment> comments = new ArrayList<>();
+  private ChangeMessage message;
+  private IdentifiedUser user;
+
+  public interface Factory {
+    PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
+  }
+
+  @Inject
+  public PublishCommentsOp(
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeMessagesUtil cmUtil,
+      CommentAdded commentAdded,
+      CommentsUtil commentsUtil,
+      EmailReviewComments.Factory email,
+      PatchSetUtil psUtil,
+      PublishCommentUtil publishCommentUtil,
+      @Assisted PatchSet.Id psId,
+      @Assisted Project.NameKey projectNameKey) {
+    this.cmUtil = cmUtil;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentAdded = commentAdded;
+    this.commentsUtil = commentsUtil;
+    this.email = email;
+    this.psId = psId;
+    this.publishCommentUtil = publishCommentUtil;
+    this.psUtil = psUtil;
+    this.projectNameKey = projectNameKey;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, UnprocessableEntityException, IOException,
+          PatchListNotAvailableException, CommentsRejectedException {
+    user = ctx.getIdentifiedUser();
+    comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
+
+    // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
+    // For example, with the "publish comments on PS upload" workflow,
+    // There are 2 ops: ReplaceOp & PublishCommentsOp, where each updates its own ChangeUpdate
+    // This is required since
+    //   1. a ChangeUpdate has only 1 change message
+    //   2. Each ChangeUpdate results in 1 commit in NoteDb
+    // We do it this way so that the execution results in 2 different commits in NoteDb
+    ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
+    publishCommentUtil.publish(ctx, changeUpdate, comments, null);
+    return insertMessage(ctx, changeUpdate);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (message == null || comments.isEmpty()) {
+      return;
+    }
+    ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
+    PatchSet ps = psUtil.get(changeNotes, psId);
+    NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
+    if (notify.shouldNotify()) {
+      email.create(notify, changeNotes, ps, user, message, comments, null, labelDelta).sendAsync();
+    }
+    commentAdded.fire(
+        changeNotes.getChange(),
+        ps,
+        ctx.getAccount(),
+        message.getMessage(),
+        ImmutableMap.of(),
+        ImmutableMap.of(),
+        ctx.getWhen());
+  }
+
+  private boolean insertMessage(ChangeContext ctx, ChangeUpdate changeUpdate) {
+    StringBuilder buf = new StringBuilder();
+    if (comments.size() == 1) {
+      buf.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      buf.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (buf.length() == 0) {
+      return false;
+    }
+    message =
+        ChangeMessagesUtil.newMessage(
+            psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, null);
+    cmUtil.addChangeMessage(changeUpdate, message);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/RequestListener.java b/java/com/google/gerrit/server/RequestListener.java
index 461b91a..b158631 100644
--- a/java/com/google/gerrit/server/RequestListener.java
+++ b/java/com/google/gerrit/server/RequestListener.java
@@ -16,6 +16,12 @@
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 
+/**
+ * Extension point that allows to listen to incoming requests.
+ *
+ * <p>This extension point is invoked each time the server executes a request from a user (REST
+ * request, SSH request, Git push/fetch).
+ */
 @ExtensionPoint
 public interface RequestListener {
   void onRequest(RequestInfo requestInfo);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 5824240..93cf0de 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -479,8 +479,10 @@
         case FAST_FORWARD:
           gitRefUpdated.fire(allUsers, u, null);
           return;
-        case IO_FAILURE:
         case LOCK_FAILURE:
+          throw new LockFailureException(
+              String.format("Update star labels on ref %s failed", refName), u);
+        case IO_FAILURE:
         case NOT_ATTEMPTED:
         case REJECTED:
         case REJECTED_CURRENT_BRANCH:
@@ -513,11 +515,12 @@
         case FORCED:
           gitRefUpdated.fire(allUsers, u, null);
           return;
+        case LOCK_FAILURE:
+          throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
         case NEW:
         case NO_CHANGE:
         case FAST_FORWARD:
         case IO_FAILURE:
-        case LOCK_FAILURE:
         case NOT_ATTEMPTED:
         case REJECTED:
         case REJECTED_CURRENT_BRANCH:
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index f8a5c5c..a6143f4 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -36,6 +37,8 @@
 
 /** Access control management for one account's access to other accounts. */
 public class AccountControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static class Factory {
     private final PermissionBackend permissionBackend;
     private final ProjectCache projectCache;
@@ -145,11 +148,19 @@
 
   private boolean canSee(OtherUser otherUser) {
     if (accountVisibility == AccountVisibility.ALL) {
+      logger.atFine().log(
+          "user %s can see account %d (accountVisibility = %s)",
+          user.getLoggableName(), otherUser.getId().get(), AccountVisibility.ALL);
       return true;
     } else if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser.getId())) {
       // I can always see myself.
+      logger.atFine().log(
+          "user %s can see own account %d", user.getLoggableName(), otherUser.getId().get());
       return true;
     } else if (viewAll()) {
+      logger.atFine().log(
+          "user %s can see account %d (view all accounts = true)",
+          user.getLoggableName(), otherUser.getId().get());
       return true;
     }
 
@@ -159,14 +170,32 @@
           Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
           for (PermissionRule rule : accountsSection.getSameGroupVisibility()) {
             if (rule.isBlock() || rule.isDeny()) {
+              logger.atFine().log(
+                  "ignoring group %s of user %s for %s account visibility check"
+                      + " because there is a blocked/denied sameGroupVisibility rule: %s",
+                  rule.getGroup().getUUID(),
+                  otherUser.getUser().getLoggableName(),
+                  AccountVisibility.SAME_GROUP,
+                  rule);
               usersGroups.remove(rule.getGroup().getUUID());
             }
           }
 
           if (user.getEffectiveGroups().containsAnyOf(usersGroups)) {
+            logger.atFine().log(
+                "user %s can see account %d because they share a group (accountVisibility = %s)",
+                user.getLoggableName(), otherUser.getId().get(), AccountVisibility.SAME_GROUP);
             return true;
           }
-          break;
+
+          logger.atFine().log(
+              "user %s cannot see account %d because they don't share a group"
+                  + " (accountVisibility = %s)",
+              user.getLoggableName(), otherUser.getId().get(), AccountVisibility.SAME_GROUP);
+          logger.atFine().log("groups of user %s: %s", user.getLoggableName(), groupsOf(user));
+          logger.atFine().log(
+              "groups of other user %s: %s", otherUser.getUser().getLoggableName(), usersGroups);
+          return false;
         }
       case VISIBLE_GROUP:
         {
@@ -174,21 +203,37 @@
           for (AccountGroup.UUID usersGroup : usersGroups) {
             try {
               if (groupControlFactory.controlFor(usersGroup).isVisible()) {
+                logger.atFine().log(
+                    "user %s can see account %d because it is member of the visible group %s"
+                        + " (accountVisibility = %s)",
+                    user.getLoggableName(),
+                    otherUser.getId().get(),
+                    usersGroup.get(),
+                    AccountVisibility.VISIBLE_GROUP);
                 return true;
               }
             } catch (NoSuchGroupException e) {
               continue;
             }
           }
-          break;
+
+          logger.atFine().log(
+              "user %s cannot see account %d because none of its groups are visible"
+                  + " (accountVisibility = %s)",
+              user.getLoggableName(), otherUser.getId().get(), AccountVisibility.VISIBLE_GROUP);
+          logger.atFine().log(
+              "groups of other user %s: %s", otherUser.getUser().getLoggableName(), usersGroups);
+          return false;
         }
       case NONE:
-        break;
+        logger.atFine().log(
+            "user %s cannot see account %d (accountVisibility = %s)",
+            user.getLoggableName(), otherUser.getId().get(), AccountVisibility.NONE);
+        return false;
       case ALL:
       default:
         throw new IllegalStateException("Bad AccountVisibility " + accountVisibility);
     }
-    return false;
   }
 
   private boolean viewAll() {
@@ -196,14 +241,19 @@
       try {
         perm.check(GlobalPermission.VIEW_ALL_ACCOUNTS);
         viewAll = true;
-      } catch (AuthException | PermissionBackendException e) {
+      } catch (AuthException e) {
+        viewAll = false;
+      } catch (PermissionBackendException e) {
+        logger.atFine().withCause(e).log(
+            "Failed to check %s global capability for user %s",
+            GlobalPermission.VIEW_ALL_ACCOUNTS, user.getLoggableName());
         viewAll = false;
       }
     }
     return viewAll;
   }
 
-  private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
+  private Set<AccountGroup.UUID> groupsOf(CurrentUser user) {
     return user.getEffectiveGroups().getKnownGroups().stream()
         .filter(a -> !SystemGroupBackend.isSystemGroup(a))
         .collect(toSet());
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 988d871..a41a36c 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -516,12 +516,22 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplier(), accountActivityPredicate());
+    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate());
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplier(), accountActivityPredicate);
+    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
+  }
+
+  public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
+  }
+
+  public Result resolveIgnoreVisibility(
+      String input, Predicate<AccountState> accountActivityPredicate)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate);
   }
 
   /**
@@ -550,13 +560,23 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, visibilitySupplier(), accountActivityPredicate());
+        input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplier() {
+  private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
     return () -> accountControlFactory.get()::canSee;
   }
 
+  private Supplier<Predicate<AccountState>> visibilitySupplierAll() {
+    return () -> all();
+  }
+
+  private Predicate<AccountState> all() {
+    return accountState -> {
+      return true;
+    };
+  }
+
   private Predicate<AccountState> accountActivityPredicate() {
     return (AccountState accountState) -> accountState.account().isActive();
   }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 1caee58..125edfe 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -41,8 +41,7 @@
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.Action;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
+import com.google.gerrit.server.update.RetryableAction.Action;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -421,8 +420,7 @@
   private Optional<AccountState> executeAccountUpdate(Action<Optional<AccountState>> action)
       throws IOException, ConfigInvalidException {
     try {
-      return retryHelper.execute(
-          ActionType.ACCOUNT_UPDATE, action, LockFailureException.class::isInstance);
+      return retryHelper.accountUpdate("updateAccount", action).call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 76c22cf..98d0d50 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -17,22 +17,16 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.UserIdentity;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.Action;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -45,16 +39,11 @@
 @Singleton
 public class Emails {
   private final ExternalIds externalIds;
-  private final Provider<InternalAccountQuery> queryProvider;
   private final RetryHelper retryHelper;
 
   @Inject
-  public Emails(
-      ExternalIds externalIds,
-      Provider<InternalAccountQuery> queryProvider,
-      RetryHelper retryHelper) {
+  public Emails(ExternalIds externalIds, RetryHelper retryHelper) {
     this.externalIds = externalIds;
-    this.queryProvider = queryProvider;
     this.retryHelper = retryHelper;
   }
 
@@ -85,7 +74,9 @@
       return accounts;
     }
 
-    return executeIndexQuery(() -> queryProvider.get().byPreferredEmail(email).stream())
+    return retryHelper
+        .accountIndexQuery("queryAccountsByPreferredEmail", q -> q.byPreferredEmail(email)).call()
+        .stream()
         .map(a -> a.account().id())
         .collect(toImmutableSet());
   }
@@ -104,8 +95,10 @@
     List<String> emailsToBackfill =
         Arrays.stream(emails).filter(e -> !result.containsKey(e)).collect(toImmutableList());
     if (!emailsToBackfill.isEmpty()) {
-      executeIndexQuery(
-              () -> queryProvider.get().byPreferredEmail(emailsToBackfill).entries().stream())
+      retryHelper
+          .accountIndexQuery(
+              "queryAccountsByPreferredEmails", q -> q.byPreferredEmail(emailsToBackfill))
+          .call().entries().stream()
           .forEach(e -> result.put(e.getKey(), e.getValue().account().id()));
     }
     return ImmutableSetMultimap.copyOf(result);
@@ -138,14 +131,4 @@
 
     return u;
   }
-
-  private <T> T executeIndexQuery(Action<T> action) {
-    try {
-      return retryHelper.execute(
-          ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new StorageException(e);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index 2228525..64fd7c6 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -29,6 +30,7 @@
 
 /** Access control management for a group of accounts managed in Gerrit. */
 public class GroupControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @Singleton
   public static class GenericFactory {
@@ -111,15 +113,46 @@
 
   /** Can this user see this group exists? */
   public boolean isVisible() {
-    /* Check for canAdministrateServer may seem redundant, but allows
-     * for visibility of all groups that are not an internal group to
-     * server administrators.
-     */
-    return user.isInternalUser()
-        || groupBackend.isVisibleToAll(group.getGroupUUID())
-        || user.getEffectiveGroups().contains(group.getGroupUUID())
-        || isOwner()
-        || canAdministrateServer();
+    if (user.isInternalUser()) {
+      logger.atFine().log(
+          "group %s is visible to internal user %s",
+          group.getGroupUUID().get(), user.getLoggableName());
+      return true;
+    }
+
+    if (groupBackend.isVisibleToAll(group.getGroupUUID())) {
+      logger.atFine().log(
+          "group %s is visible to user %s (group is visible to all users)",
+          group.getGroupUUID().get(), user.getLoggableName());
+      return true;
+    }
+
+    if (user.getEffectiveGroups().contains(group.getGroupUUID())) {
+      logger.atFine().log(
+          "group %s is visible to user %s (user is member of the group)",
+          group.getGroupUUID().get(), user.getLoggableName());
+      return true;
+    }
+
+    if (isOwner()) {
+      logger.atFine().log(
+          "group %s is visible to user %s (user is owner of the group)",
+          group.getGroupUUID().get(), user.getLoggableName());
+      return true;
+    }
+
+    // The check for canAdministrateServer may seem redundant, but it's needed to make external
+    // groups visible to server administrators.
+    if (canAdministrateServer()) {
+      logger.atFine().log(
+          "group %s is visible to user %s (user is admin)",
+          group.getGroupUUID().get(), user.getLoggableName());
+      return true;
+    }
+
+    logger.atFine().log(
+        "group %s is not visible to user %s", group.getGroupUUID().get(), user.getLoggableName());
+    return false;
   }
 
   public boolean isOwner() {
@@ -127,11 +160,28 @@
       return isOwner;
     }
 
-    // Keep this logic in sync with VisibleRefFilter#isOwner(...).
+    // Keep this logic in sync with DefaultRefFilter#isGroupOwner(...).
     if (group instanceof GroupDescription.Internal) {
       AccountGroup.UUID ownerUUID = ((GroupDescription.Internal) group).getOwnerGroupUUID();
-      isOwner = getUser().getEffectiveGroups().contains(ownerUUID) || canAdministrateServer();
+      if (getUser().getEffectiveGroups().contains(ownerUUID)) {
+        logger.atFine().log(
+            "user %s is owner of group %s", user.getLoggableName(), group.getGroupUUID().get());
+        isOwner = true;
+      } else if (canAdministrateServer()) {
+        logger.atFine().log(
+            "user %s is owner of group %s (user is admin)",
+            user.getLoggableName(), group.getGroupUUID().get());
+        isOwner = true;
+      } else {
+        logger.atFine().log(
+            "user %s is not an owner of group %s",
+            user.getLoggableName(), group.getGroupUUID().get());
+        isOwner = false;
+      }
     } else {
+      logger.atFine().log(
+          "user %s is not an owner of external group %s",
+          user.getLoggableName(), group.getGroupUUID().get());
       isOwner = false;
     }
     return isOwner;
@@ -141,7 +191,12 @@
     try {
       perm.check(GlobalPermission.ADMINISTRATE_SERVER);
       return true;
-    } catch (AuthException | PermissionBackendException denied) {
+    } catch (AuthException e) {
+      return false;
+    } catch (PermissionBackendException e) {
+      logger.atFine().log(
+          "Failed to check %s global capability for user %s",
+          GlobalPermission.ADMINISTRATE_SERVER, user.getLoggableName());
       return false;
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 7883b11..27fde83 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -125,7 +125,7 @@
       logger.atFine().log("Evict parent groups of %s", groupId.get());
       parentGroups.invalidate(groupId);
 
-      if (!AccountGroup.isInternalGroup(groupId)) {
+      if (!groupId.isInternalGroup()) {
         logger.atFine().log("Evict external group %s", groupId.get());
         external.invalidate(EXTERNAL_NAME);
       }
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUuid.java
similarity index 95%
rename from java/com/google/gerrit/server/account/GroupUUID.java
rename to java/com/google/gerrit/server/account/GroupUuid.java
index ac83482..652420d 100644
--- a/java/com/google/gerrit/server/account/GroupUUID.java
+++ b/java/com/google/gerrit/server/account/GroupUuid.java
@@ -20,7 +20,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 
-public class GroupUUID {
+public class GroupUuid {
   public static AccountGroup.UUID make(String groupName, PersonIdent creator) {
     MessageDigest md = Constants.newMessageDigest();
     md.update(Constants.encode("group " + groupName + "\n"));
@@ -29,5 +29,5 @@
     return AccountGroup.uuid(ObjectId.fromRaw(md.digest()).name());
   }
 
-  private GroupUUID() {}
+  private GroupUuid() {}
 }
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 798b4e8..d56ed07 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -24,6 +24,12 @@
 import javax.naming.NamingException;
 import javax.security.auth.login.LoginException;
 
+/**
+ * Interface between Gerrit and an account system.
+ *
+ * <p>This interface provides the glue layer between the Gerrit and external account/authentication
+ * systems (eg. LDAP, OpenID).
+ */
 public interface Realm {
   /** Can the end-user modify this field of their own account? */
   boolean allowsEdit(AccountFieldName field);
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index fb3d4ea..32ed694 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -32,6 +32,7 @@
 import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/** Toggler for account active state. */
 @Singleton
 public class SetInactiveFlag {
   private final PluginSetContext<AccountActivationValidationListener>
@@ -106,6 +107,6 @@
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
-    return alreadyActive.get() ? Response.ok("") : Response.created("");
+    return alreadyActive.get() ? Response.ok() : Response.created();
   }
 }
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index e2ffe9b..5e63875 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -28,7 +28,12 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 
-/** Named Queries for user accounts. */
+/**
+ * Named Queries for user accounts.
+ *
+ * <p>Users can define aliases for change queries. These are stored as versioned account data and
+ * (de)serialized with this class.
+ */
 public class VersionedAccountQueries extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/api/ApiUtil.java b/java/com/google/gerrit/server/api/ApiUtil.java
index c5b8b12..8a8e37c 100644
--- a/java/com/google/gerrit/server/api/ApiUtil.java
+++ b/java/com/google/gerrit/server/api/ApiUtil.java
@@ -32,7 +32,7 @@
   public static RestApiException asRestApiException(String msg, Exception e)
       throws RuntimeException {
     Throwables.throwIfUnchecked(e);
-    return e instanceof RestApiException ? (RestApiException) e : new RestApiException(msg, e);
+    return e instanceof RestApiException ? (RestApiException) e : RestApiException.wrap(msg, e);
   }
 
   private ApiUtil() {}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a04be30..0d640d9 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -48,8 +48,10 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -62,7 +64,6 @@
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
@@ -96,6 +97,7 @@
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
+import com.google.gerrit.server.restapi.change.RevertSubmission;
 import com.google.gerrit.server.restapi.change.Reviewers;
 import com.google.gerrit.server.restapi.change.Revisions;
 import com.google.gerrit.server.restapi.change.SetReadyForReview;
@@ -132,6 +134,7 @@
   private final ChangeResource change;
   private final Abandon abandon;
   private final Revert revert;
+  private final RevertSubmission revertSubmission;
   private final Restore restore;
   private final CreateMergePatchSet updateByMerge;
   private final Provider<SubmittedTogether> submittedTogether;
@@ -181,6 +184,7 @@
       ListReviewers listReviewers,
       Abandon abandon,
       Revert revert,
+      RevertSubmission revertSubmission,
       Restore restore,
       CreateMergePatchSet updateByMerge,
       Provider<SubmittedTogether> submittedTogether,
@@ -219,6 +223,7 @@
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
+    this.revertSubmission = revertSubmission;
     this.reviewers = reviewers;
     this.revisions = revisions;
     this.reviewerApi = reviewerApi;
@@ -319,7 +324,7 @@
   @Override
   public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
     try {
-      SetPrivateOp.Input input = new SetPrivateOp.Input(message);
+      InputWithMessage input = new InputWithMessage(message);
       if (value) {
         postPrivate.apply(change, input);
       } else {
@@ -358,6 +363,15 @@
   }
 
   @Override
+  public RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException {
+    try {
+      return revertSubmission.apply(change, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot revert a change submission", e);
+    }
+  }
+
+  @Override
   public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
     try {
       return updateByMerge.apply(change, in).value();
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index 7f0feba..1b0f0c5 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
+import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -200,9 +200,9 @@
   }
 
   @Override
-  public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
+  public void modifyFile(String filePath, FileContentInput input) throws RestApiException {
     try {
-      changeEditsPut.apply(changeResource, filePath, newContent);
+      changeEditsPut.apply(changeResource, filePath, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot modify file of change edit", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 01dfe36..1a4cbb8 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.DescriptionInput;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -71,6 +72,7 @@
 import com.google.gerrit.server.restapi.change.Fixes;
 import com.google.gerrit.server.restapi.change.GetCommit;
 import com.google.gerrit.server.restapi.change.GetDescription;
+import com.google.gerrit.server.restapi.change.GetFixPreview;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -126,6 +128,7 @@
   private final ListRevisionComments listComments;
   private final ListRobotComments listRobotComments;
   private final ApplyFix applyFix;
+  private final GetFixPreview getFixPreview;
   private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
@@ -169,6 +172,7 @@
       ListRevisionComments listComments,
       ListRobotComments listRobotComments,
       ApplyFix applyFix,
+      GetFixPreview getFixPreview,
       Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
@@ -211,6 +215,7 @@
     this.robotComments = robotComments;
     this.listRobotComments = listRobotComments;
     this.applyFix = applyFix;
+    this.getFixPreview = getFixPreview;
     this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
@@ -452,6 +457,15 @@
   }
 
   @Override
+  public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
+    try {
+      return getFixPreview.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get fix preview", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 3932177..59c396a 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -69,7 +69,11 @@
 
   @Override
   public void disable() throws RestApiException {
-    disable.apply(resource, new Input());
+    try {
+      disable.apply(resource, new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot disable plugin", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/projects/LabelApiImpl.java b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
new file mode 100644
index 0000000..ad7ec31
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.LabelApi;
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.CreateLabel;
+import com.google.gerrit.server.restapi.project.DeleteLabel;
+import com.google.gerrit.server.restapi.project.GetLabel;
+import com.google.gerrit.server.restapi.project.LabelsCollection;
+import com.google.gerrit.server.restapi.project.SetLabel;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class LabelApiImpl implements LabelApi {
+  interface Factory {
+    LabelApiImpl create(ProjectResource project, String label);
+  }
+
+  private final LabelsCollection labels;
+  private final CreateLabel createLabel;
+  private final GetLabel getLabel;
+  private final SetLabel setLabel;
+  private final DeleteLabel deleteLabel;
+  private final ProjectCache projectCache;
+  private final String label;
+
+  private ProjectResource project;
+
+  @Inject
+  LabelApiImpl(
+      LabelsCollection labels,
+      CreateLabel createLabel,
+      GetLabel getLabel,
+      SetLabel setLabel,
+      DeleteLabel deleteLabel,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String label) {
+    this.labels = labels;
+    this.createLabel = createLabel;
+    this.getLabel = getLabel;
+    this.setLabel = setLabel;
+    this.deleteLabel = deleteLabel;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.label = label;
+  }
+
+  @Override
+  public LabelApi create(LabelDefinitionInput input) throws RestApiException {
+    try {
+      createLabel.apply(project, IdString.fromDecoded(label), input);
+
+      // recreate project resource because project state was updated by creating the new label and
+      // needs to be reloaded
+      project =
+          new ProjectResource(projectCache.checkedGet(project.getNameKey()), project.getUser());
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create branch", e);
+    }
+  }
+
+  @Override
+  public LabelDefinitionInfo get() throws RestApiException {
+    try {
+      return getLabel.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get label", e);
+    }
+  }
+
+  @Override
+  public LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException {
+    try {
+      return setLabel.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update label", e);
+    }
+  }
+
+  @Override
+  public void delete(@Nullable String commitMessage) throws RestApiException {
+    try {
+      deleteLabel.apply(resource(), new InputWithCommitMessage(commitMessage));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete label", e);
+    }
+  }
+
+  private LabelResource resource() throws RestApiException, PermissionBackendException {
+    return labels.parse(project, IdString.fromDecoded(label));
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/Module.java b/java/com/google/gerrit/server/api/projects/Module.java
index f1e21d28..8df5495 100644
--- a/java/com/google/gerrit/server/api/projects/Module.java
+++ b/java/com/google/gerrit/server/api/projects/Module.java
@@ -28,5 +28,6 @@
     factory(ChildProjectApiImpl.Factory.class);
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
+    factory(LabelApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 1ac905d..6d7fc15 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -37,13 +37,16 @@
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.HeadInput;
 import com.google.gerrit.extensions.api.projects.IndexProjectInput;
+import com.google.gerrit.extensions.api.projects.LabelApi;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -73,7 +76,9 @@
 import com.google.gerrit.server.restapi.project.IndexChanges;
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
+import com.google.gerrit.server.restapi.project.ListLabels;
 import com.google.gerrit.server.restapi.project.ListTags;
+import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.restapi.project.PutConfig;
 import com.google.gerrit.server.restapi.project.PutDescription;
@@ -127,6 +132,9 @@
   private final SetParent setParent;
   private final Index index;
   private final IndexChanges indexChanges;
+  private final Provider<ListLabels> listLabels;
+  private final PostLabels postLabels;
+  private final LabelApiImpl.Factory labelApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -162,6 +170,9 @@
       SetParent setParent,
       Index index,
       IndexChanges indexChanges,
+      Provider<ListLabels> listLabels,
+      PostLabels postLabels,
+      LabelApiImpl.Factory labelApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -197,6 +208,9 @@
         setParent,
         index,
         indexChanges,
+        listLabels,
+        postLabels,
+        labelApi,
         null);
   }
 
@@ -234,6 +248,9 @@
       SetParent setParent,
       Index index,
       IndexChanges indexChanges,
+      Provider<ListLabels> listLabels,
+      PostLabels postLabels,
+      LabelApiImpl.Factory labelApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -269,6 +286,9 @@
         setParent,
         index,
         indexChanges,
+        listLabels,
+        postLabels,
+        labelApi,
         name);
   }
 
@@ -306,6 +326,9 @@
       SetParent setParent,
       Index index,
       IndexChanges indexChanges,
+      Provider<ListLabels> listLabels,
+      PostLabels postLabels,
+      LabelApiImpl.Factory labelApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -341,6 +364,9 @@
     this.name = name;
     this.index = index;
     this.indexChanges = indexChanges;
+    this.listLabels = listLabels;
+    this.postLabels = postLabels;
+    this.labelApi = labelApi;
   }
 
   @Override
@@ -672,4 +698,36 @@
     }
     return project;
   }
+
+  @Override
+  public ListLabelsRequest labels() {
+    return new ListLabelsRequest() {
+      @Override
+      public List<LabelDefinitionInfo> get() throws RestApiException {
+        try {
+          return listLabels.get().withInherited(inherited).apply(checkExists()).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list labels", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public LabelApi label(String labelName) throws RestApiException {
+    try {
+      return labelApi.create(checkExists(), labelName);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse label", e);
+    }
+  }
+
+  @Override
+  public void labels(BatchLabelInput input) throws RestApiException {
+    try {
+      postLabels.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update labels", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 5d25d1a..f311b35 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -156,7 +156,7 @@
           .withStart(r.getStart())
           .apply();
     } catch (StorageException e) {
-      throw new RestApiException("Cannot query projects", e);
+      throw asRestApiException("Cannot query projects", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 95929d3..a7ecde4 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -57,7 +57,6 @@
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
-        "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 2b068aa..8f7e360 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -47,6 +47,10 @@
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * Creates persistent caches depending on gerrit.config parameters. If the cache.directory property
+ * is unset, it will fall back to in-memory caches.
+ */
 @Singleton
 class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index d493b31..e87cf70 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -136,6 +136,7 @@
     copy._number = changeInfo._number;
     copy.requirements = changeInfo.requirements;
     copy.revertOf = changeInfo.revertOf;
+    copy.submissionId = changeInfo.submissionId;
     copy.starred = changeInfo.starred;
     copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
@@ -143,6 +144,8 @@
     copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
     copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
+    copy.cherryPickOfChange = changeInfo.cherryPickOfChange;
+    copy.cherryPickOfPatchSet = changeInfo.cherryPickOfPatchSet;
     return copy;
   }
 
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 0299f10..6cf7a8f 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -79,11 +79,14 @@
       // abandonInactiveOpenChanges skips failures instead of throwing, so retrying will never
       // actually happen. For the purposes of this class that is fine: they'll get tried again the
       // next time the scheduled task is run.
-      retryHelper.execute(
-          updateFactory -> {
-            abandonUtil.abandonInactiveOpenChanges(updateFactory);
-            return null;
-          });
+      retryHelper
+          .changeUpdate(
+              "abandonInactiveOpenChanges",
+              updateFactory -> {
+                abandonUtil.abandonInactiveOpenChanges(updateFactory);
+                return null;
+              })
+          .call();
     } catch (RestApiException | UpdateException e) {
       logger.atSevere().withCause(e).log("Failed to cleanup changes.");
     }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 1bf5103..770f36c 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -110,6 +111,7 @@
   private final String refName;
 
   // Fields exposed as setters.
+  private PatchSet.Id cherryPickOf;
   private Change.Status status;
   private String topic;
   private String message;
@@ -188,6 +190,7 @@
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
+    change.setCherryPickOf(cherryPickOf);
     change.setPrivate(isPrivate);
     change.setWorkInProgress(workInProgress);
     change.setReviewStarted(!workInProgress);
@@ -226,6 +229,11 @@
     return this;
   }
 
+  public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) {
+    this.cherryPickOf = cherryPickOf;
+    return this;
+  }
+
   public ChangeInserter setMessage(String message) {
     this.message = message;
     return this;
@@ -377,6 +385,9 @@
     if (revertOf != null) {
       update.setRevertOf(revertOf.get());
     }
+    if (cherryPickOf != null) {
+      update.setCherryPickOf(cherryPickOf.getCommaSeparatedChangeAndPatchSetId());
+    }
 
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
@@ -386,7 +397,7 @@
         psUtil.insert(
             ctx.getRevWalk(), update, psId, commitId, newGroups, pushCert, patchSetDescription);
 
-    /* TODO: fixStatus is used here because the tests
+    /* TODO: fixStatusToMerged is used here because the tests
      * (byStatusClosed() in AbstractQueryChangesTest)
      * insert changes that are already merged,
      * and setStatus may not be used to set the Status to merged
@@ -394,7 +405,11 @@
      * is it possible to make the tests use the merge code path,
      * instead of setting the status directly?
      */
-    update.fixStatus(change.getStatus());
+    if (change.getStatus() == Change.Status.MERGED) {
+      update.fixStatusToMerged(new SubmissionId(change));
+    } else {
+      update.setStatus(change.getStatus());
+    }
 
     reviewerAdditions =
         reviewerAdder.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
@@ -551,10 +566,16 @@
             reviewerInputs.stream(),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
-                    change, patchSetInfo.getAuthor().getAccount(), NotifyHandling.NONE)),
+                    change,
+                    patchSetInfo.getCommitId(),
+                    patchSetInfo.getAuthor().getAccount(),
+                    NotifyHandling.NONE)),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
-                    change, patchSetInfo.getCommitter().getAccount(), NotifyHandling.NONE)))
+                    change,
+                    patchSetInfo.getCommitId(),
+                    patchSetInfo.getCommitter().getAccount(),
+                    NotifyHandling.NONE)))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 3b7a2c4..70e7967 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -29,7 +29,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
-import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_MERGEABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
@@ -138,6 +137,7 @@
           COMMIT_FOOTERS,
           CURRENT_ACTIONS,
           CURRENT_COMMIT,
+          DETAILED_LABELS, // may need to load ChangeNotes to check remove reviewer permissions
           MESSAGES);
 
   @Singleton
@@ -218,7 +218,7 @@
   private final Metrics metrics;
   private final RevisionJson revisionJson;
   private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
-  private final boolean excludeMergeableInChangeInfo;
+  private final boolean includeMergeable;
   private final boolean lazyLoad;
 
   private AccountLoader accountLoader;
@@ -256,8 +256,7 @@
     this.metrics = metrics;
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
-    this.excludeMergeableInChangeInfo =
-        cfg.getBoolean("change", "api", "excludeMergeableInChangeInfo", false);
+    this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
 
@@ -524,7 +523,7 @@
       if (str.isOk()) {
         out.submitType = str.type;
       }
-      if (!excludeMergeableInChangeInfo && !has(SKIP_MERGEABLE)) {
+      if (includeMergeable) {
         out.mergeable = cd.isMergeable();
       }
       if (has(SUBMITTABLE)) {
@@ -586,6 +585,13 @@
       out.plugins = pluginDefinedAttributesFactory.get().create(cd);
     }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
+    out.submissionId = cd.change().getSubmissionId();
+    out.cherryPickOfChange =
+        cd.change().getCherryPickOf() != null
+            ? cd.change().getCherryPickOf().changeId().get()
+            : null;
+    out.cherryPickOfPatchSet =
+        cd.change().getCherryPickOf() != null ? cd.change().getCherryPickOf().get() : null;
 
     if (has(REVIEWER_UPDATES)) {
       out.reviewerUpdates = reviewerUpdates(cd);
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 6cd3726..6f2e1ef 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -23,6 +23,9 @@
   }
 
   public String revertChangeDefaultMessage;
+  public String revertSubmissionDefaultMessage;
+  public String revertSubmissionUserMessage;
+  public String revertSubmissionOfRevertSubmissionUserMessage;
 
   public String reviewerCantSeeChange;
   public String reviewerInvalid;
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 0374a1c..61616c0 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
@@ -169,26 +170,29 @@
   public Result check(ChangeNotes notes, @Nullable FixInput f) {
     requireNonNull(notes);
     try {
-      return retryHelper.execute(
-          buf -> {
-            try {
-              reset();
-              this.updateFactory = buf;
-              this.notes = notes;
-              fix = f;
-              checkImpl();
-              return result();
-            } finally {
-              if (rw != null) {
-                rw.getObjectReader().close();
-                rw.close();
-                oi.close();
-              }
-              if (repo != null) {
-                repo.close();
-              }
-            }
-          });
+      return retryHelper
+          .changeUpdate(
+              "checkChangeConsistency",
+              buf -> {
+                try {
+                  reset();
+                  this.updateFactory = buf;
+                  this.notes = notes;
+                  fix = f;
+                  checkImpl();
+                  return result();
+                } finally {
+                  if (rw != null) {
+                    rw.getObjectReader().close();
+                    rw.close();
+                    oi.close();
+                  }
+                  if (repo != null) {
+                    repo.close();
+                  }
+                }
+              })
+          .call();
     } catch (RestApiException e) {
       return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
     } catch (UpdateException e) {
@@ -221,7 +225,9 @@
         problem("Missing change owner: " + change().getOwner());
       }
     } catch (IOException | ConfigInvalidException e) {
-      error("Failed to look up owner", e);
+      ProblemInfo problem = problem("Failed to look up owner");
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
     }
   }
 
@@ -233,7 +239,9 @@
             String.format("Current patch set %d not found", change().currentPatchSetId().get()));
       }
     } catch (StorageException e) {
-      error("Failed to look up current patch set", e);
+      ProblemInfo problem = problem("Failed to look up current patch set");
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
     }
   }
 
@@ -245,9 +253,15 @@
       rw = new RevWalk(oi.newReader());
       return true;
     } catch (RepositoryNotFoundException e) {
-      return error("Destination repository not found: " + project, e);
+      ProblemInfo problem = problem("Destination repository not found: " + project);
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
+      return false;
     } catch (IOException e) {
-      return error("Failed to open repository: " + project, e);
+      ProblemInfo problem = problem("Failed to open repository: " + project);
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
+      return false;
     }
   }
 
@@ -257,7 +271,10 @@
       // Iterate in descending order.
       all = PS_ID_ORDER.sortedCopy(psUtil.byChange(notes));
     } catch (StorageException e) {
-      return error("Failed to look up patch sets", e);
+      ProblemInfo problem = problem("Failed to look up patch sets");
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
+      return false;
     }
     patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();
 
@@ -267,7 +284,9 @@
           repo.getRefDatabase()
               .exactRef(all.stream().map(ps -> ps.id().toRefName()).toArray(String[]::new));
     } catch (IOException e) {
-      error("error reading refs", e);
+      ProblemInfo problem = problem("Error reading refs");
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
       refs = Collections.emptyMap();
     }
 
@@ -426,7 +445,8 @@
             continue;
           }
         } catch (StorageException e) {
-          warn(e);
+          logger.atWarning().withCause(e).log(
+              "Error in consistency check of change %s", notes.getChangeId());
           // Include this patch set; should cause an error below, which is good.
         }
         thisCommitPsIds.add(psId);
@@ -476,7 +496,10 @@
           break;
       }
     } catch (IOException e) {
-      error("Error looking up expected merged commit " + fix.expectMergedAs, e);
+      ProblemInfo problem =
+          problem("Error looking up expected merged commit " + fix.expectMergedAs);
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s: %s", notes.getChangeId(), problem);
     }
   }
 
@@ -557,7 +580,8 @@
       insertPatchSetProblem.status = Status.FIXED;
       insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
     } catch (StorageException | IOException | UpdateException | RestApiException e) {
-      warn(e);
+      logger.atWarning().withCause(e).log(
+          "Error in consistency check of change %s", notes.getChangeId());
       for (ProblemInfo pi : currProblems) {
         pi.status = Status.FIX_FAILED;
         pi.outcome = "Error inserting merged patch set";
@@ -576,7 +600,8 @@
     @Override
     public boolean updateChange(ChangeContext ctx) {
       ctx.getChange().setStatus(Change.Status.MERGED);
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+          .fixStatusToMerged(new SubmissionId(ctx.getChange()));
       p.status = Status.FIXED;
       p.outcome = "Marked change as merged";
       return true;
@@ -758,18 +783,6 @@
     return problems.get(problems.size() - 1);
   }
 
-  private boolean error(String msg, Throwable t) {
-    problem(msg);
-    // TODO(dborowitz): Expose stack trace to administrators.
-    warn(t);
-    return false;
-  }
-
-  private void warn(Throwable t) {
-    logger.atWarning().withCause(t).log(
-        "Error in consistency check of change %s", notes.getChangeId());
-  }
-
   private Result result() {
     return Result.create(notes, problems);
   }
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index 5c7946c..49c1fe2 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -100,7 +100,7 @@
 
   public BinaryResult getContent(
       Repository repo, ProjectState project, ObjectId revstr, String path)
-      throws IOException, ResourceNotFoundException {
+      throws IOException, ResourceNotFoundException, BadRequestException {
     try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revstr);
       try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
@@ -114,6 +114,10 @@
           return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
         }
 
+        if (mode == org.eclipse.jgit.lib.FileMode.TREE) {
+          throw new BadRequestException("cannot retrieve content of directories");
+        }
+
         ObjectLoader obj = repo.open(id, OBJ_BLOB);
         byte[] raw;
         try {
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index a823975..aca4fb0 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -30,6 +31,8 @@
 import com.google.inject.Singleton;
 import java.util.Map;
 import java.util.TreeMap;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
@@ -42,31 +45,44 @@
   }
 
   public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException {
+      throws ResourceConflictException, PatchListNotAvailableException {
     return toFileInfoMap(change, patchSet.commitId(), null);
   }
 
   public Map<String, FileInfo> toFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
-      throws PatchListNotAvailableException {
+      throws ResourceConflictException, PatchListNotAvailableException {
     ObjectId a = base != null ? base.commitId() : null;
     return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
   }
 
   public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
-      throws PatchListNotAvailableException {
+      throws ResourceConflictException, PatchListNotAvailableException {
     return toFileInfoMap(
         change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
   }
 
   private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
-      throws PatchListNotAvailableException {
+      throws ResourceConflictException, PatchListNotAvailableException {
     return toFileInfoMap(change.getProject(), key);
   }
 
   public Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
-      throws PatchListNotAvailableException {
-    PatchList list = patchListCache.get(key, project);
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchList list;
+    try {
+      list = patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof ExecutionException) {
+        cause = cause.getCause();
+      }
+      if (cause instanceof NoMergeBaseException) {
+        throw new ResourceConflictException(
+            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+      }
+      throw e;
+    }
 
     Map<String, FileInfo> files = new TreeMap<>();
     for (PatchListEntry e : list.getPatches()) {
diff --git a/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java b/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
new file mode 100644
index 0000000..26e7a89
--- /dev/null
+++ b/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * State that is used to decide if {@code mergeable} should be included in the REST API or the
+ * change index.
+ *
+ * <p>Computing mergeability of a change is an expensive operation - especially for Gerrit
+ * installations with a large number of open changes or large repositories. Therefore, we want to
+ * control when this computation is performed.
+ */
+public enum MergeabilityComputationBehavior {
+  NEVER(false, false),
+  REF_UPDATED_AND_CHANGE_REINDEX(true, false),
+  API_REF_UPDATED_AND_CHANGE_REINDEX(true, true);
+
+  private final boolean includeInIndex;
+  private final boolean includeInApi;
+
+  MergeabilityComputationBehavior(boolean includeInIndex, boolean includeInApi) {
+    this.includeInIndex = includeInIndex;
+    this.includeInApi = includeInApi;
+  }
+
+  /** Returns a {@link MergeabilityComputationBehavior} constructed from a Gerrit server config. */
+  public static MergeabilityComputationBehavior fromConfig(Config cfg) {
+    return cfg.getEnum(
+        "change",
+        null,
+        "mergeabilityComputationBehavior",
+        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX);
+  }
+
+  /** Whether {@code mergeable} should be included in the change API. */
+  public boolean includeInApi() {
+    return includeInApi;
+  }
+
+  /** Whether {@code mergeable} should be included in the change index. */
+  public boolean includeInIndex() {
+    return includeInIndex;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index ba6ba21..4f66492 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -81,6 +81,7 @@
 import java.util.function.Function;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class ReviewerAdder {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -125,12 +126,16 @@
   }
 
   public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
-      Change change, @Nullable Account.Id accountId, NotifyHandling notify) {
+      Change change, ObjectId commitId, @Nullable Account.Id accountId, NotifyHandling notify) {
     if (accountId == null || accountId.equals(change.getOwner())) {
       // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
       return Optional.empty();
     }
 
+    logger.atFine().log(
+        "Adding account %d from author/committer identity of commit %s as reviewer to change %d",
+        accountId.get(), commitId.name(), change.getChangeId());
+
     InternalAddReviewerInput in = new InternalAddReviewerInput();
     in.reviewer = accountId.toString();
     in.state = REVIEWER;
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index fbd14c4..a4994fd 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
@@ -311,9 +312,13 @@
     }
 
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
-      out.files = fileInfoJson.toFileInfoMap(c, in);
-      out.files.remove(Patch.COMMIT_MSG);
-      out.files.remove(Patch.MERGE_LIST);
+      try {
+        out.files = fileInfoJson.toFileInfoMap(c, in);
+        out.files.remove(Patch.COMMIT_MSG);
+        out.files.remove(Patch.MERGE_LIST);
+      } catch (ResourceConflictException e) {
+        logger.atWarning().withCause(e).log("creating file list failed");
+      }
     }
 
     if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/SetCherryPickOp.java b/java/com/google/gerrit/server/change/SetCherryPickOp.java
new file mode 100644
index 0000000..d271923
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SetCherryPickOp.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetCherryPickOp implements BatchUpdateOp {
+  public interface Factory {
+    SetCherryPickOp create(PatchSet.Id cherryPickOf);
+  }
+
+  private final PatchSet.Id newCherryPickOf;
+
+  @Inject
+  SetCherryPickOp(@Assisted PatchSet.Id newCherryPickOf) {
+    this.newCherryPickOf = newCherryPickOf;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    Change change = ctx.getChange();
+    if (newCherryPickOf.equals(change.getCherryPickOf())) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    update.setCherryPickOf(newCherryPickOf.getCommaSeparatedChangeAndPatchSetId());
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index 28d178d..382a4f6 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -34,25 +35,15 @@
 import com.google.inject.assistedinject.Assisted;
 
 public class SetPrivateOp implements BatchUpdateOp {
-  public static class Input {
-    String message;
-
-    public Input() {}
-
-    public Input(String message) {
-      this.message = message;
-    }
-  }
-
   public interface Factory {
-    SetPrivateOp create(boolean isPrivate, @Nullable Input input);
+    SetPrivateOp create(boolean isPrivate, @Nullable InputWithMessage input);
   }
 
   private final PrivateStateChanged privateStateChanged;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final boolean isPrivate;
-  @Nullable private final Input input;
+  @Nullable private final InputWithMessage input;
 
   private Change change;
   private PatchSet ps;
@@ -64,7 +55,7 @@
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       @Assisted boolean isPrivate,
-      @Assisted @Nullable Input input) {
+      @Assisted @Nullable InputWithMessage input) {
     this.privateStateChanged = privateStateChanged;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 78edadab..283cff8 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
@@ -34,15 +35,15 @@
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
-  public static class Input {
-    @Nullable public String message;
-
+  public static class Input extends InputWithMessage {
     @Nullable public NotifyHandling notify;
 
-    public Input() {}
+    public Input() {
+      this(null);
+    }
 
-    public Input(String message) {
-      this.message = message;
+    public Input(@Nullable String message) {
+      super(message);
     }
   }
 
diff --git a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
index 4d41ed7..bd51fc7 100644
--- a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
+++ b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -25,6 +27,8 @@
 
 @Singleton
 public class ChangeCleanupConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static String SECTION = "changeCleanup";
   private static String KEY_ABANDON_AFTER = "abandonAfter";
   private static String KEY_ABANDON_IF_MERGEABLE = "abandonIfMergeable";
@@ -48,12 +52,26 @@
     this.urlFormatter = urlFormatter;
     schedule = ScheduleConfig.createSchedule(cfg, SECTION);
     abandonAfter = readAbandonAfter(cfg);
-    abandonIfMergeable = cfg.getBoolean(SECTION, null, KEY_ABANDON_IF_MERGEABLE, true);
+    boolean indexMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
+    if (!indexMergeable) {
+      if (!readAbandonIfMergeable(cfg)) {
+        logger.atWarning().log(
+            "index.change.indexMergeable is disabled; %s.%s=false will be ineffective",
+            SECTION, KEY_ABANDON_IF_MERGEABLE);
+      }
+      abandonIfMergeable = true;
+    } else {
+      abandonIfMergeable = readAbandonIfMergeable(cfg);
+    }
     cleanupAccountPatchReview =
         cfg.getBoolean(SECTION, null, KEY_CLEANUP_ACCOUNT_PATCH_REVIEW, false);
     abandonMessage = readAbandonMessage(cfg);
   }
 
+  private boolean readAbandonIfMergeable(Config cfg) {
+    return cfg.getBoolean(SECTION, null, KEY_ABANDON_IF_MERGEABLE, true);
+  }
+
   private long readAbandonAfter(Config cfg) {
     long abandonAfter =
         ConfigUtil.getTimeUnit(cfg, SECTION, null, KEY_ABANDON_AFTER, 0, TimeUnit.MILLISECONDS);
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 3b9c40e..012e4cd 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.ExceptionHookImpl;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
@@ -127,6 +128,8 @@
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
+import com.google.gerrit.server.git.validators.CommentCountValidator;
+import com.google.gerrit.server.git.validators.CommentSizeValidator;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidators;
@@ -157,6 +160,7 @@
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
+import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionCollection;
 import com.google.gerrit.server.permissions.SectionSortCache;
@@ -260,6 +264,7 @@
     factory(LabelsJson.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
+    factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
     factory(InboundEmailRejectionSender.Factory.class);
@@ -393,6 +398,7 @@
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
     DynamicSet.setOf(binder(), ExceptionHook.class);
+    DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
@@ -404,6 +410,13 @@
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
+    bind(CommentValidator.class)
+        .annotatedWith(Exports.named(CommentCountValidator.class.getSimpleName()))
+        .to(CommentCountValidator.class);
+    bind(CommentValidator.class)
+        .annotatedWith(Exports.named(CommentSizeValidator.class.getSimpleName()))
+        .to(CommentSizeValidator.class);
+
     DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
diff --git a/java/com/google/gerrit/server/config/OperatorAliasConfig.java b/java/com/google/gerrit/server/config/OperatorAliasConfig.java
new file mode 100644
index 0000000..0c5fc6e
--- /dev/null
+++ b/java/com/google/gerrit/server/config/OperatorAliasConfig.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class OperatorAliasConfig {
+  private static final String SECTION = "operator-alias";
+  private static final String SUBSECTION_CHANGE = "change";
+  private final Config cfg;
+  private final Map<String, String> changeQueryOperatorAliases;
+
+  @Inject
+  OperatorAliasConfig(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    changeQueryOperatorAliases = new HashMap<>();
+    loadChangeOperatorAliases();
+  }
+
+  public Map<String, String> getChangeQueryOperatorAliases() {
+    return changeQueryOperatorAliases;
+  }
+
+  private void loadChangeOperatorAliases() {
+    for (String name : cfg.getNames(SECTION, SUBSECTION_CHANGE)) {
+      changeQueryOperatorAliases.put(name, cfg.getString(SECTION, SUBSECTION_CHANGE, name));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index a6da2b9..bc51e2a 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -31,6 +31,8 @@
   public String url;
   public String commitMessage;
   public List<String> hashtags;
+  public Integer cherryPickOfChange;
+  public Integer cherryPickOfPatchSet;
 
   public Long createdOn;
   public Long lastUpdated;
diff --git a/java/com/google/gerrit/server/diff/DiffInfoCreator.java b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
new file mode 100644
index 0000000..c29ffc8
--- /dev/null
+++ b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
@@ -0,0 +1,299 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.diff;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.common.data.PatchScript.DisplayMethod;
+import com.google.gerrit.common.data.PatchScript.PatchScriptFileInfo;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
+import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.diff.Edit;
+
+/** Creates and fills a new {@link DiffInfo} object based on diff between files. */
+public class DiffInfoCreator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final ImmutableMap<Patch.ChangeType, ChangeType> CHANGE_TYPE =
+      Maps.immutableEnumMap(
+          new ImmutableMap.Builder<Patch.ChangeType, ChangeType>()
+              .put(Patch.ChangeType.ADDED, ChangeType.ADDED)
+              .put(Patch.ChangeType.MODIFIED, ChangeType.MODIFIED)
+              .put(Patch.ChangeType.DELETED, ChangeType.DELETED)
+              .put(Patch.ChangeType.RENAMED, ChangeType.RENAMED)
+              .put(Patch.ChangeType.COPIED, ChangeType.COPIED)
+              .put(Patch.ChangeType.REWRITE, ChangeType.REWRITE)
+              .build());
+
+  private final DiffWebLinksProvider webLinksProvider;
+  private final boolean intraline;
+  private final ProjectState state;
+
+  public DiffInfoCreator(
+      ProjectState state, DiffWebLinksProvider webLinksProvider, boolean intraline) {
+    this.webLinksProvider = webLinksProvider;
+    this.state = state;
+    this.intraline = intraline;
+  }
+
+  /* Returns the {@link DiffInfo} to display for end-users */
+  public DiffInfo create(PatchScript ps, DiffSide sideA, DiffSide sideB) {
+    DiffInfo result = new DiffInfo();
+
+    ImmutableList<DiffWebLinkInfo> links = webLinksProvider.getDiffLinks();
+    result.webLinks = links.isEmpty() ? null : links;
+
+    if (ps.isBinary()) {
+      result.binary = true;
+    }
+    result.metaA = createFileMeta(sideA).orElse(null);
+    result.metaB = createFileMeta(sideB).orElse(null);
+
+    if (intraline) {
+      if (ps.hasIntralineTimeout()) {
+        result.intralineStatus = IntraLineStatus.TIMEOUT;
+      } else if (ps.hasIntralineFailure()) {
+        result.intralineStatus = IntraLineStatus.FAILURE;
+      } else {
+        result.intralineStatus = IntraLineStatus.OK;
+      }
+      logger.atFine().log("intralineStatus = %s", result.intralineStatus);
+    }
+
+    result.changeType = CHANGE_TYPE.get(ps.getChangeType());
+    logger.atFine().log("changeType = %s", result.changeType);
+    if (result.changeType == null) {
+      throw new IllegalStateException("unknown change type: " + ps.getChangeType());
+    }
+
+    if (ps.getPatchHeader().size() > 0) {
+      result.diffHeader = ps.getPatchHeader();
+    }
+    result.content = calculateDiffContentEntries(ps);
+    return result;
+  }
+
+  private static List<ContentEntry> calculateDiffContentEntries(PatchScript ps) {
+    ContentCollector contentCollector = new ContentCollector(ps);
+    Set<Edit> editsDueToRebase = ps.getEditsDueToRebase();
+    for (Edit edit : ps.getEdits()) {
+      logger.atFine().log("next edit = %s", edit);
+
+      if (edit.getType() == Edit.Type.EMPTY) {
+        logger.atFine().log("skip empty edit");
+        continue;
+      }
+      contentCollector.addCommon(edit.getBeginA());
+
+      checkState(
+          contentCollector.nextA == edit.getBeginA(),
+          "nextA = %s; want %s",
+          contentCollector.nextA,
+          edit.getBeginA());
+      checkState(
+          contentCollector.nextB == edit.getBeginB(),
+          "nextB = %s; want %s",
+          contentCollector.nextB,
+          edit.getBeginB());
+      switch (edit.getType()) {
+        case DELETE:
+        case INSERT:
+        case REPLACE:
+          List<Edit> internalEdit =
+              edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
+          boolean dueToRebase = editsDueToRebase.contains(edit);
+          contentCollector.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
+          break;
+        case EMPTY:
+        default:
+          throw new IllegalStateException();
+      }
+    }
+    contentCollector.addCommon(ps.getA().getSize());
+
+    return contentCollector.lines;
+  }
+
+  private Optional<FileMeta> createFileMeta(DiffSide side) {
+    PatchScriptFileInfo fileInfo = side.fileInfo();
+    if (fileInfo.displayMethod == DisplayMethod.NONE) {
+      return Optional.empty();
+    }
+    FileMeta result = new FileMeta();
+    result.name = side.fileName();
+    result.contentType =
+        FileContentUtil.resolveContentType(
+            state, side.fileName(), fileInfo.mode, fileInfo.mimeType);
+    result.lines = fileInfo.content.getSize();
+    ImmutableList<WebLinkInfo> links = webLinksProvider.getFileWebLinks(side.type());
+    result.webLinks = links.isEmpty() ? null : links;
+    result.commitId = fileInfo.commitId;
+    return Optional.of(result);
+  }
+
+  private static class ContentCollector {
+
+    private final List<ContentEntry> lines;
+    private final SparseFileContent.Accessor fileA;
+    private final SparseFileContent.Accessor fileB;
+    private final boolean ignoreWS;
+
+    private int nextA;
+    private int nextB;
+
+    ContentCollector(PatchScript ps) {
+      lines = Lists.newArrayListWithExpectedSize(ps.getEdits().size() + 2);
+      fileA = ps.getA().createAccessor();
+      fileB = ps.getB().createAccessor();
+      ignoreWS = ps.isIgnoreWhitespace();
+    }
+
+    void addCommon(int end) {
+      logger.atFine().log("addCommon: end = %d", end);
+
+      end = Math.min(end, fileA.getSize());
+      logger.atFine().log("end = %d", end);
+
+      if (nextA >= end) {
+        logger.atFine().log("nextA >= end: nextA = %d, end = %d", nextA, end);
+        return;
+      }
+
+      while (nextA < end) {
+        logger.atFine().log("nextA < end: nextA = %d, end = %d", nextA, end);
+
+        if (!fileA.contains(nextA)) {
+          logger.atFine().log("fileA does not contain nextA: nextA = %d", nextA);
+
+          int endRegion = Math.min(end, nextA == 0 ? fileA.first() : fileA.next(nextA - 1));
+          int len = endRegion - nextA;
+          entry().skip = len;
+          nextA = endRegion;
+          nextB += len;
+
+          logger.atFine().log("setting: nextA = %d, nextB = %d", nextA, nextB);
+          continue;
+        }
+
+        ContentEntry e = null;
+        for (int i = nextA; i == nextA && i < end; i = fileA.next(i), nextA++, nextB++) {
+          if (ignoreWS && fileB.contains(nextB)) {
+            if (e == null || e.common == null) {
+              logger.atFine().log("create new common entry: nextA = %d, nextB = %d", nextA, nextB);
+              e = entry();
+              e.a = Lists.newArrayListWithCapacity(end - nextA);
+              e.b = Lists.newArrayListWithCapacity(end - nextA);
+              e.common = true;
+            }
+            e.a.add(fileA.get(nextA));
+            e.b.add(fileB.get(nextB));
+          } else {
+            if (e == null || e.common != null) {
+              logger.atFine().log(
+                  "create new non-common entry: nextA = %d, nextB = %d", nextA, nextB);
+              e = entry();
+              e.ab = Lists.newArrayListWithCapacity(end - nextA);
+            }
+            e.ab.add(fileA.get(nextA));
+          }
+        }
+      }
+    }
+
+    void addDiff(int endA, int endB, List<Edit> internalEdit, boolean dueToRebase) {
+      logger.atFine().log(
+          "addDiff: endA = %d, endB = %d, numberOfInternalEdits = %d, dueToRebase = %s",
+          endA, endB, internalEdit != null ? internalEdit.size() : 0, dueToRebase);
+
+      int lenA = endA - nextA;
+      int lenB = endB - nextB;
+      logger.atFine().log("lenA = %d, lenB = %d", lenA, lenB);
+      checkState(lenA > 0 || lenB > 0);
+
+      logger.atFine().log("create non-common entry");
+      ContentEntry e = entry();
+      if (lenA > 0) {
+        logger.atFine().log("lenA > 0: lenA = %d", lenA);
+        e.a = Lists.newArrayListWithCapacity(lenA);
+        for (; nextA < endA; nextA++) {
+          e.a.add(fileA.get(nextA));
+        }
+      }
+      if (lenB > 0) {
+        logger.atFine().log("lenB > 0: lenB = %d", lenB);
+        e.b = Lists.newArrayListWithCapacity(lenB);
+        for (; nextB < endB; nextB++) {
+          e.b.add(fileB.get(nextB));
+        }
+      }
+      if (internalEdit != null && !internalEdit.isEmpty()) {
+        logger.atFine().log("processing internal edits");
+
+        e.editA = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
+        e.editB = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
+        int lastA = 0;
+        int lastB = 0;
+        for (Edit edit : internalEdit) {
+          logger.atFine().log("internal edit = %s", edit);
+
+          if (edit.getBeginA() != edit.getEndA()) {
+            logger.atFine().log(
+                "edit.getBeginA() != edit.getEndA(): edit.getBeginA() = %d, edit.getEndA() = %d",
+                edit.getBeginA(), edit.getEndA());
+            e.editA.add(
+                ImmutableList.of(edit.getBeginA() - lastA, edit.getEndA() - edit.getBeginA()));
+            lastA = edit.getEndA();
+            logger.atFine().log("lastA = %d", lastA);
+          }
+          if (edit.getBeginB() != edit.getEndB()) {
+            logger.atFine().log(
+                "edit.getBeginB() != edit.getEndB(): edit.getBeginB() = %d, edit.getEndB() = %d",
+                edit.getBeginB(), edit.getEndB());
+            e.editB.add(
+                ImmutableList.of(edit.getBeginB() - lastB, edit.getEndB() - edit.getBeginB()));
+            lastB = edit.getEndB();
+            logger.atFine().log("lastB = %d", lastB);
+          }
+        }
+      }
+      e.dueToRebase = dueToRebase ? true : null;
+    }
+
+    private ContentEntry entry() {
+      ContentEntry e = new ContentEntry();
+      lines.add(e);
+      return e;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/diff/DiffSide.java b/java/com/google/gerrit/server/diff/DiffSide.java
new file mode 100644
index 0000000..28c7810
--- /dev/null
+++ b/java/com/google/gerrit/server/diff/DiffSide.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.diff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.data.PatchScript.PatchScriptFileInfo;
+
+/** Contains settings for one of two sides in diff view. Each diff view has exactly 2 sides. */
+@AutoValue
+public abstract class DiffSide {
+  public enum Type {
+    SIDE_A,
+    SIDE_B
+  }
+
+  public static DiffSide create(PatchScriptFileInfo fileInfo, String fileName, Type type) {
+    return new AutoValue_DiffSide(fileInfo, fileName, type);
+  }
+
+  public abstract PatchScriptFileInfo fileInfo();
+
+  public abstract String fileName();
+
+  public abstract Type type();
+}
diff --git a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
new file mode 100644
index 0000000..0f71b17
--- /dev/null
+++ b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+/** Provider for different types of links which can be displayed in a diff view. */
+public interface DiffWebLinksProvider {
+
+  /** Returns links associated with the diff view */
+  ImmutableList<DiffWebLinkInfo> getDiffLinks();
+
+  /** Returns links associated with the diff side */
+  ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType);
+}
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index ee93fbf..128388d 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -17,12 +17,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -229,7 +231,12 @@
         createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     } else {
       createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
@@ -335,7 +342,12 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     } else {
       createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
@@ -388,7 +400,12 @@
         createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      return updateEdit(
+          notes.getProjectName(),
+          repository,
+          optionalChangeEdit.get(),
+          newEditCommit,
+          nowTimestamp);
     }
     return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
   }
@@ -540,7 +557,13 @@
       throws IOException {
     Change change = notes.getChange();
     String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
+    updateReference(
+        notes.getProjectName(),
+        repository,
+        editRefName,
+        ObjectId.zeroId(),
+        newEditCommitId,
+        timestamp);
     reindex(change);
 
     RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
@@ -553,11 +576,16 @@
   }
 
   private ChangeEdit updateEdit(
-      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
+      Project.NameKey projectName,
+      Repository repository,
+      ChangeEdit changeEdit,
+      ObjectId newEditCommitId,
+      Timestamp timestamp)
       throws IOException {
     String editRefName = changeEdit.getRefName();
     RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
+    updateReference(
+        projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
     reindex(changeEdit.getChange());
 
     RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
@@ -566,6 +594,7 @@
   }
 
   private void updateReference(
+      Project.NameKey projectName,
       Repository repository,
       String refName,
       ObjectId currentObjectId,
@@ -580,14 +609,12 @@
     ru.setForceUpdate(true);
     try (RevWalk revWalk = new RevWalk(repository)) {
       RefUpdate.Result res = ru.update(revWalk);
+      String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
+      if (res == RefUpdate.Result.LOCK_FAILURE) {
+        throw new LockFailureException(message, ru);
+      }
       if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
-        throw new IOException(
-            "cannot update "
-                + ru.getName()
-                + " in "
-                + repository.getDirectory()
-                + ": "
-                + ru.getResult());
+        throw new IOException(message);
       }
     }
   }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6ba30bf..710916e 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -168,7 +169,10 @@
 
       RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
       PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
-      PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, squashed);
+      PatchSetInserter inserter =
+          patchSetInserterFactory
+              .create(notes, psId, squashed)
+              .setSendEmail(!change.isWorkInProgress());
 
       StringBuilder message =
           new StringBuilder("Patch Set ").append(inserter.getPatchSetId().get()).append(": ");
@@ -248,9 +252,10 @@
       case NEW:
       case NO_CHANGE:
         break;
+      case LOCK_FAILURE:
+        throw new LockFailureException(String.format("Failed to delete ref %s", refName), ru);
       case FAST_FORWARD:
       case IO_FAILURE:
-      case LOCK_FAILURE:
       case NOT_ATTEMPTED:
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 3f22d7f..9422c18 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -144,6 +144,10 @@
     a.createdOn = change.getCreatedOn().getTime() / 1000L;
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
+    a.cherryPickOfChange =
+        change.getCherryPickOf() != null ? change.getCherryPickOf().changeId().get() : null;
+    a.cherryPickOfPatchSet =
+        change.getCherryPickOf() != null ? change.getCherryPickOf().get() : null;
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
index a5800fb..b7ee043 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.events.ChangeEvent;
 import java.sql.Timestamp;
 
+/** Base class for all change events. */
 public abstract class AbstractChangeEvent implements ChangeEvent {
   private final ChangeInfo changeInfo;
   private final AccountInfo who;
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
index 6b72b5e..9d4d299 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.events.RevisionEvent;
 import java.sql.Timestamp;
 
+/** Base class for all revision events. */
 public abstract class AbstractRevisionEvent extends AbstractChangeEvent implements RevisionEvent {
 
   private final RevisionInfo revisionInfo;
diff --git a/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
index b692cf5..47fdd0e 100644
--- a/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
+++ b/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -21,6 +21,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/** Helper class to fire an event when a user has signed a contributor agreement. */
 @Singleton
 public class AgreementSignup {
   private final PluginSetContext<AgreementSignupListener> listeners;
@@ -40,6 +41,7 @@
     listeners.runEach(l -> l.onAgreementSignup(event));
   }
 
+  /** Event to be fired when a user has signed a contributor agreement. */
   private static class Event extends AbstractNoNotifyEvent
       implements AgreementSignupListener.Event {
     private final AccountInfo account;
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index a76b69b..2189690 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -27,6 +27,7 @@
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a user has been set as assignee on a change. */
 @Singleton
 public class AssigneeChanged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -58,6 +59,7 @@
     }
   }
 
+  /** Event to be fired when a user has been set as assignee on a change. */
   private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
     private final AccountInfo oldAssignee;
 
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index b27ffb9..c7a9283 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -34,6 +34,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a change has been abandoned. */
 @Singleton
 public class ChangeAbandoned {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -78,6 +79,7 @@
     }
   }
 
+  /** Event to be fired when a change has been abandoned. */
   private static class Event extends AbstractRevisionEvent
       implements ChangeAbandonedListener.Event {
     private final String reason;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index df71f27..1ed6209 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -27,6 +27,7 @@
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a change has been deleted. */
 @Singleton
 public class ChangeDeleted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -52,6 +53,7 @@
     }
   }
 
+  /** Event to be fired when a change has been deleted. */
   private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
     Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
       super(change, deleter, when, NotifyHandling.ALL);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index add1c51..06d0008 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -34,6 +34,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a change has been merged. */
 @Singleton
 public class ChangeMerged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -72,6 +73,7 @@
     }
   }
 
+  /** Event to be fired when a change has been merged. */
   private static class Event extends AbstractRevisionEvent implements ChangeMergedListener.Event {
     private final String newRevisionId;
 
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index fa91dbd..1af56d0 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -34,6 +34,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a change has been restored. */
 @Singleton
 public class ChangeRestored {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -72,6 +73,7 @@
     }
   }
 
+  /** Event to be fired when a change has been restored. */
   private static class Event extends AbstractRevisionEvent implements ChangeRestoredListener.Event {
 
     private String reason;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index 5fb004f..d608c52 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -25,6 +25,7 @@
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a change has been reverted. */
 @Singleton
 public class ChangeReverted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -50,6 +51,7 @@
     }
   }
 
+  /** Event to be fired when a change has been reverted. */
   private static class Event extends AbstractChangeEvent implements ChangeRevertedListener.Event {
     private final ChangeInfo revertChange;
 
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index e45b206..151298c 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -36,6 +36,7 @@
 import java.sql.Timestamp;
 import java.util.Map;
 
+/** Helper class to fire an event when a comment or vote has been added to a change. */
 @Singleton
 public class CommentAdded {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -82,6 +83,7 @@
     }
   }
 
+  /** Event to be fired when a comment or vote has been added to a change. */
   private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
 
     private final String comment;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 3dcf3b8..a35140a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -46,8 +46,8 @@
 /**
  * Formats change and revision info objects to serve as payload for Gerrit events.
  *
- * <p>Uses configurable options ({@code event.payload.listChangeOptions}) to decide which fields to
- * populate.
+ * <p>Uses configurable options ({@code event.payload.listChangeOptions}) to decide which change
+ * fields to populate.
  */
 @Singleton
 public class EventUtil {
diff --git a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 99f105e..6ed0a08 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -27,6 +27,7 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
+/** Helper class to fire an event when a Git reference has been updated. */
 @Singleton
 public class GitReferenceUpdated {
   public static final GitReferenceUpdated DISABLED =
@@ -153,6 +154,7 @@
     listeners.runEach(l -> l.onGitReferenceUpdated(event));
   }
 
+  /** Event to be fired when a Git reference has been updated. */
   public static class Event implements GitReferenceUpdatedListener.Event {
     private final String projectName;
     private final String ref;
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index df60ec0..5d9c5c2 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -30,6 +30,7 @@
 import java.util.Collection;
 import java.util.Set;
 
+/** Helper class to fire an event when the hashtags of a change has been edited. */
 @Singleton
 public class HashtagsEdited {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -63,6 +64,7 @@
     }
   }
 
+  /** Event to be fired when the hashtags of a change has been edited. */
   private static class Event extends AbstractChangeEvent implements HashtagsEditedListener.Event {
 
     private Collection<String> updatedHashtags;
diff --git a/java/com/google/gerrit/server/extensions/events/PluginEvent.java b/java/com/google/gerrit/server/extensions/events/PluginEvent.java
index 60d27c9..6723588 100644
--- a/java/com/google/gerrit/server/extensions/events/PluginEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/PluginEvent.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.events.PluginEventListener;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/** Helper class to let plugins fire a plugin-specific event. */
 @Singleton
+@UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class PluginEvent {
   private final PluginSetContext<PluginEventListener> listeners;
 
@@ -36,6 +39,7 @@
     listeners.runEach(l -> l.onPluginEvent(e));
   }
 
+  /** Event to be fired by plugins. */
   private static class Event extends AbstractNoNotifyEvent implements PluginEventListener.Event {
     private final String pluginName;
     private final String type;
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index 72adff7..bcc6b8e 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -33,6 +33,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when the private flag of a change has been toggled. */
 @Singleton
 public class PrivateStateChanged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -67,6 +68,7 @@
     }
   }
 
+  /** Event to be fired when the private flag of a change has been toggled. */
   private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 1af428cb..35e7828 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -36,6 +36,7 @@
 import java.sql.Timestamp;
 import java.util.List;
 
+/** Helper class to fire an event when reviewers have been added to a change. */
 @Singleton
 public class ReviewerAdded {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -79,6 +80,7 @@
     }
   }
 
+  /** Event to be fired when reviewers have been added to a change. */
   private static class Event extends AbstractRevisionEvent implements ReviewerAddedListener.Event {
     private final List<AccountInfo> reviewers;
 
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 61632f2..147f980 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -36,6 +36,7 @@
 import java.sql.Timestamp;
 import java.util.Map;
 
+/** Helper class to fire an event when a reviewer has been deleted from a change. */
 @Singleton
 public class ReviewerDeleted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -86,6 +87,7 @@
     }
   }
 
+  /** Event to be fired when a reviewer has been deleted from a change. */
   private static class Event extends AbstractRevisionEvent
       implements ReviewerDeletedListener.Event {
     private final AccountInfo reviewer;
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index bdfa8c1..8179e9a 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -35,6 +35,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when a revision has been created for a change. */
 @Singleton
 public class RevisionCreated {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -93,6 +94,7 @@
     }
   }
 
+  /** Event to be fired when a revision has been created for a change. */
   private static class Event extends AbstractRevisionEvent
       implements RevisionCreatedListener.Event {
 
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index cb982a1..e4089b1 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -27,6 +27,7 @@
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when the topic of a change has been edited. */
 @Singleton
 public class TopicEdited {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -53,6 +54,7 @@
     }
   }
 
+  /** Event to be fired when the topic of a change has been edited. */
   private static class Event extends AbstractChangeEvent implements TopicEditedListener.Event {
     private final String oldTopic;
 
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 8533a65..ef4e461 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -36,6 +36,7 @@
 import java.sql.Timestamp;
 import java.util.Map;
 
+/** Helper class to fire an event when a vote has been deleted from a change. */
 @Singleton
 public class VoteDeleted {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -86,6 +87,7 @@
     }
   }
 
+  /** Event to be fired when a vote has been deleted from a change. */
   private static class Event extends AbstractRevisionEvent implements VoteDeletedListener.Event {
     private final AccountInfo reviewer;
     private final Map<String, ApprovalInfo> approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 16c5e25..359a3a8 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -33,6 +33,7 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 
+/** Helper class to fire an event when the work-in-progress state of a change has been toggled. */
 @Singleton
 public class WorkInProgressStateChanged {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -68,6 +69,7 @@
     }
   }
 
+  /** Event to be fired when the work-in-progress state of a change has been toggled. */
   private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
new file mode 100644
index 0000000..9ea628e
--- /dev/null
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -0,0 +1,427 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
+import com.google.gerrit.server.patch.Text;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import org.eclipse.jgit.diff.Edit;
+
+/**
+ * Produces final version of an input content with all fixes applied together with list of edits.
+ */
+public class FixCalculator {
+  private static final Comparator<FixReplacement> ASC_RANGE_FIX_REPLACEMENT_COMPARATOR =
+      Comparator.comparing(fixReplacement -> fixReplacement.range);
+
+  private FixCalculator() {}
+
+  /**
+   * Returns a result of applying fixes to an original content.
+   *
+   * @param originalContent is a text to which fixes must be applied
+   * @param fixReplacements is a list of fixes to be applied
+   * @throws ResourceConflictException if the fixReplacements contains invalid data (for example, if
+   *     an item points to an invalid range or if some ranges are intersected).
+   */
+  public static String getNewFileContent(
+      String originalContent, List<FixReplacement> fixReplacements)
+      throws ResourceConflictException {
+    FixResult fixResult = calculateFix(new Text(originalContent.getBytes(UTF_8)), fixReplacements);
+    return fixResult.text.getString(0, fixResult.text.size(), false);
+  }
+
+  /**
+   * Returns a result of applying fixes to an original content and list of applied edits.
+   *
+   * @param originalText is a text to which fixes must be applied
+   * @param fixReplacements is a list of fixes to be applied
+   * @return {@link FixResult}
+   * @throws ResourceConflictException if the fixReplacements contains invalid data (for example, if
+   *     an item points to an invalid range or if some ranges are intersected).
+   */
+  public static FixResult calculateFix(Text originalText, List<FixReplacement> fixReplacements)
+      throws ResourceConflictException {
+    List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
+    sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
+    if (!sortedReplacements.isEmpty() && sortedReplacements.get(0).range.startLine <= 0) {
+      throw new ResourceConflictException(
+          String.format(
+              "Cannot calculate fix replacement for range %s",
+              toString(sortedReplacements.get(0).range)));
+    }
+    ContentBuilder builder = new ContentBuilder(originalText);
+    for (FixReplacement fixReplacement : sortedReplacements) {
+      try {
+        builder.addReplacement(fixReplacement);
+      } catch (IndexOutOfBoundsException e) {
+        throw new ResourceConflictException(
+            String.format(
+                "Cannot calculate fix replacement for range %s", toString(fixReplacement.range)),
+            e);
+      }
+    }
+    return builder.build();
+  }
+
+  private static String toString(Comment.Range range) {
+    return String.format(
+        "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar);
+  }
+  /*
+  Algorithm:
+  Input:
+    Original text (aka srcText)
+    Sorted list of replacements in ascending order, where each replacement has:
+        srcRange - part of the original text to be
+                   replaced, inserted or deleted (see {@link Comment.Range} for details)
+        replacement - text to be set instead of srcRange
+    Replacement ranges must not intersect.
+
+  Output:
+    Final text (aka finalText)
+    List of Edit, where each Edit is an instance of {@link ReplaceEdit}
+      Each ReplaceEdit cover one or more lines in the original text
+      Each ReplaceEdit contains one or more Edit for intraline edits
+    See {@link ReplaceEdit} and {@link Edit} for details.
+  *
+  Note: The algorithm is implemented in this way to avoid string.replace operations.
+  It has complexity O(len(replacements) + max(len(originalText), len(finalText)) )
+
+  Main steps:
+  - set srcPos to start of the original text. It is like a cursor position in the original text.
+  - set dstPos to start of the final text.  It is like a cursor position in the final text.
+  - the finalText initially empty
+
+  - for each replacement:
+       - append text between a previous and a current replacement to the finalText
+           (because replacements were sorted, this part of text can't be changed by
+             following replacements). I.e. append substring of srcText between srcPos
+             and replacement.srcRange.start to the finalText
+           Update srcPos and dstPos - set them at the end of appended text
+           (i.e. srcPos points to the position before replacement.srcRange.start,
+            dstPos points to the position where replacement.text should be inserted)
+       - set dstReplacementStart = dstPos
+       - append replacement.text to the finalText.
+           Update srcPos and dstPos accordingly (i.e. srcPos points to the position after
+           replacement.srcRange, dstPos points to the position in the finalText after
+           the appended replacement.text).
+       - set dstReplacementEnd = dstPos
+       - dstRange = (dstReplacementStart, dstReplacementEnd) - is the range in the finalText.
+       - srcRange = (replacement.Start, replacement.End) -  is the range in the original text *
+
+       - If previously created ReplaceEdit ends on the same or previous line as srcRange.startLine,
+           then intraline edit is added to it (and ReplaceEdit endLine must be updated if needed);
+           srcRange and dstRange together is used to calculate intraline Edit
+         otherwise
+          create new ReplaceEdit and add intraline Edit to it
+          srcRange and dstRange together is used to calculate intraline Edit
+
+  - append text after the last replacements,
+      i.e. add part of srcText after srcPos to the finalText
+
+  - Return the finalText and all created ReplaceEdits
+
+  Implementation notes:
+  1) The intraline Edits inside ReplaceEdit stores positions relative to ReplaceEdit start.
+  2) srcPos and dstPos tracks current position as 3 numbers:
+  - line number
+  - column number
+  - textPos - absolute position from the start of the text. The textPos is used to calculate
+  relative positions of Edit inside ReplaceEdit
+     */
+  private static class ContentBuilder {
+    private static class FixRegion {
+      int startSrcLine;
+      int startDstLine;
+      int startSrcPos;
+      int startDstPos;
+      List<Edit> internalEdits;
+
+      FixRegion() {
+        this.internalEdits = new ArrayList<>();
+      }
+    }
+
+    private final ContentProcessor contentProcessor;
+    final ImmutableList.Builder<Edit> edits;
+    FixRegion currentRegion;
+
+    ContentBuilder(Text src) {
+      this.contentProcessor = new ContentProcessor(src);
+      this.edits = new ImmutableList.Builder<>();
+    }
+
+    void addReplacement(FixReplacement replacement) {
+      if (shouldStartNewEdit(replacement)) {
+        finishExistingEdit();
+      }
+      // processSrcContent expects that line number is 0-based,
+      // but replacement.range.startLine is 1-based, so subtract 1
+      processSrcContent(replacement.range.startLine - 1, replacement.range.startChar, true);
+      processReplacement(replacement);
+    }
+
+    Text getNewText() {
+      return new Text(contentProcessor.sb.toString().getBytes(UTF_8));
+    }
+
+    void finish() {
+      finishExistingEdit();
+      if (contentProcessor.hasMoreLines()) {
+        contentProcessor.appendLinesToEndOfContent();
+      }
+    }
+
+    public FixResult build() {
+      finish();
+      return new FixResult(edits.build(), this.getNewText());
+    }
+
+    private void finishExistingEdit() {
+      if (contentProcessor.srcPosition.column > 0 || contentProcessor.dstPosition.column > 0) {
+        contentProcessor.processToEndOfLine(true);
+      }
+      if (currentRegion != null) {
+        int endSrc = contentProcessor.srcPosition.line;
+        if (contentProcessor.srcPosition.column > 0) {
+          endSrc++;
+        }
+        int endDst = contentProcessor.dstPosition.line;
+        if (contentProcessor.dstPosition.column > 0) {
+          endDst++;
+        }
+        ReplaceEdit edit =
+            new ReplaceEdit(
+                currentRegion.startSrcLine,
+                endSrc,
+                currentRegion.startDstLine,
+                endDst,
+                currentRegion.internalEdits);
+        currentRegion = null;
+        edits.add(edit);
+      }
+    }
+
+    private boolean shouldStartNewEdit(FixReplacement replacement) {
+      if (currentRegion == null) {
+        return true;
+      }
+      // New edit must be started if there is at least one unchanged line after the last edit
+      // Subtract 1 from replacement.range.startLine because it is a 1-based line number,
+      // and contentProcessor.srcPosition.line is a 0-based line number
+      return replacement.range.startLine - 1 > contentProcessor.srcPosition.line + 1;
+    }
+
+    private void processSrcContent(int toLine, int toColumn, boolean append)
+        throws IndexOutOfBoundsException {
+      // toLine >= currentSrcLineIndex
+      if (toLine == contentProcessor.srcPosition.line) {
+        contentProcessor.processLineToColumn(toColumn, append);
+      } else {
+        contentProcessor.processToEndOfLine(append);
+        contentProcessor.processMultiline(toLine, append);
+        contentProcessor.processLineToColumn(toColumn, append);
+      }
+    }
+
+    private void processReplacement(FixReplacement fix) {
+      if (currentRegion == null) {
+        currentRegion = new FixRegion();
+        currentRegion.startSrcLine = contentProcessor.srcPosition.line;
+        currentRegion.startSrcPos = contentProcessor.srcPosition.getLineStartPos();
+        currentRegion.startDstLine = contentProcessor.dstPosition.line;
+        currentRegion.startDstPos = contentProcessor.dstPosition.getLineStartPos();
+      }
+      int srcStartPos = contentProcessor.srcPosition.textPos;
+      int dstStartPos = contentProcessor.dstPosition.textPos;
+      contentProcessor.appendReplacement(fix.replacement);
+      processSrcContent(fix.range.endLine - 1, fix.range.endChar, false);
+
+      currentRegion.internalEdits.add(
+          new Edit(
+              srcStartPos - currentRegion.startSrcPos,
+              contentProcessor.srcPosition.textPos - currentRegion.startSrcPos,
+              dstStartPos - currentRegion.startDstPos,
+              contentProcessor.dstPosition.textPos - currentRegion.startDstPos));
+    }
+  }
+
+  private static class ContentProcessor {
+    static class ContentPosition {
+      int line;
+      int column;
+      int textPos;
+
+      void appendMultilineContent(int lineCount, int charCount) {
+        line += lineCount;
+        column = 0;
+        textPos += charCount;
+      }
+
+      void appendLineEndedWithEOLMark(int charCount) {
+        textPos += charCount;
+        line++;
+        column = 0;
+      }
+
+      void appendStringWithoutEOLMark(int charCount) {
+        textPos += charCount;
+        column += charCount;
+      }
+
+      int getLineStartPos() {
+        return textPos - column;
+      }
+    }
+
+    private final StringBuilder sb;
+    final ContentPosition srcPosition;
+    final ContentPosition dstPosition;
+    String currentSrcLine;
+    Text src;
+    boolean endOfSource;
+
+    ContentProcessor(Text src) {
+      this.src = src;
+      sb = new StringBuilder(src.size());
+      srcPosition = new ContentPosition();
+      dstPosition = new ContentPosition();
+      endOfSource = src.size() == 0;
+    }
+
+    void processMultiline(int toLine, boolean append) {
+      if (endOfSource || toLine <= srcPosition.line) {
+        return;
+      }
+      int fromLine = srcPosition.line;
+      String lines = src.getString(fromLine, toLine, false);
+      int lineCount = toLine - fromLine;
+      int charCount = lines.length();
+      srcPosition.appendMultilineContent(lineCount, charCount);
+
+      if (append) {
+        sb.append(lines);
+        dstPosition.appendMultilineContent(lineCount, charCount);
+      }
+      currentSrcLine = null;
+      endOfSource = srcPosition.line >= src.size();
+    }
+
+    void processToEndOfLine(boolean append) {
+      if (endOfSource) {
+        return;
+      }
+      String srcLine = getCurrentSrcLine();
+      int from = srcPosition.column;
+      int charCount = srcLine.length() - from;
+      boolean lastLineNoEOLMark = srcPosition.line >= src.size() - 1 && src.isMissingNewlineAtEnd();
+      if (!lastLineNoEOLMark) {
+        srcPosition.appendLineEndedWithEOLMark(charCount);
+        endOfSource = srcPosition.line >= src.size();
+      } else {
+        srcPosition.appendStringWithoutEOLMark(charCount);
+        endOfSource = true;
+      }
+      if (append) {
+        sb.append(srcLine, from, srcLine.length());
+        if (!lastLineNoEOLMark) {
+          dstPosition.appendLineEndedWithEOLMark(charCount);
+        } else {
+          dstPosition.appendStringWithoutEOLMark(charCount);
+        }
+      }
+      currentSrcLine = null;
+    }
+
+    void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
+      if (to == 0) {
+        return;
+      }
+      String srcLine = getCurrentSrcLine();
+      if (to > srcLine.length()) {
+        throw new IndexOutOfBoundsException("Parameter to is out of string");
+      } else if (to == srcLine.length()) {
+        if (srcPosition.line < src.size() - 1 || !src.isMissingNewlineAtEnd()) {
+          throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
+        }
+      }
+      int from = srcPosition.column;
+      int charCount = to - from;
+      srcPosition.appendStringWithoutEOLMark(charCount);
+      if (append) {
+        sb.append(srcLine, from, to);
+        dstPosition.appendStringWithoutEOLMark(charCount);
+      }
+    }
+
+    void appendLinesToEndOfContent() {
+      processMultiline(src.size(), true);
+    }
+
+    void appendReplacement(String replacement) {
+      if (replacement.length() == 0) {
+        return;
+      }
+      sb.append(replacement);
+      int lastNewLinePos = -1;
+      int newLineMarkCount = 0;
+      while (true) {
+        int index = replacement.indexOf('\n', lastNewLinePos + 1);
+        if (index < 0) {
+          break;
+        }
+        lastNewLinePos = index;
+        newLineMarkCount++;
+      }
+      if (newLineMarkCount > 0) {
+        dstPosition.appendMultilineContent(newLineMarkCount, lastNewLinePos + 1);
+      }
+      dstPosition.appendStringWithoutEOLMark(replacement.length() - lastNewLinePos - 1);
+    }
+
+    boolean hasMoreLines() {
+      return !endOfSource;
+    }
+
+    private String getCurrentSrcLine() {
+      if (currentSrcLine == null) {
+        currentSrcLine = src.getString(srcPosition.line, srcPosition.line + 1, false);
+      }
+      return currentSrcLine;
+    }
+  }
+
+  /** The result of applying fix to a file content */
+  public static class FixResult {
+    /** List of edits to transform an original text to a final text (with all fixes applied) */
+    public final ImmutableList<Edit> edits;
+    /** Final text with all applied fixes */
+    public final Text text;
+
+    FixResult(ImmutableList<Edit> edits, Text text) {
+      this.edits = edits;
+      this.text = text;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
index a1682fe..72a5176 100644
--- a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
+++ b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -18,8 +18,8 @@
 import static java.util.stream.Collectors.groupingBy;
 
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -31,7 +31,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,9 +40,6 @@
 @Singleton
 public class FixReplacementInterpreter {
 
-  private static final Comparator<FixReplacement> ASC_RANGE_FIX_REPLACEMENT_COMPARATOR =
-      Comparator.comparing(fixReplacement -> fixReplacement.range);
-
   private final FileContentUtil fileContentUtil;
 
   @Inject
@@ -69,7 +65,8 @@
       ProjectState projectState,
       ObjectId patchSetCommitId,
       List<FixReplacement> fixReplacements)
-      throws ResourceNotFoundException, IOException, ResourceConflictException {
+      throws BadRequestException, ResourceNotFoundException, IOException,
+          ResourceConflictException {
     requireNonNull(fixReplacements, "Fix replacements must not be null");
 
     Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
@@ -91,62 +88,20 @@
       ObjectId patchSetCommitId,
       String filePath,
       List<FixReplacement> fixReplacements)
-      throws ResourceNotFoundException, IOException, ResourceConflictException {
+      throws BadRequestException, ResourceNotFoundException, IOException,
+          ResourceConflictException {
     String fileContent = getFileContent(repository, projectState, patchSetCommitId, filePath);
-    String newFileContent = getNewFileContent(fileContent, fixReplacements);
+    String newFileContent = FixCalculator.getNewFileContent(fileContent, fixReplacements);
+
     return new ChangeFileContentModification(filePath, RawInputUtil.create(newFileContent));
   }
 
   private String getFileContent(
       Repository repository, ProjectState projectState, ObjectId patchSetCommitId, String filePath)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, BadRequestException, IOException {
     try (BinaryResult fileContent =
         fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath)) {
       return fileContent.asString();
     }
   }
-
-  private static String getNewFileContent(String fileContent, List<FixReplacement> fixReplacements)
-      throws ResourceConflictException {
-    List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
-    sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
-
-    LineIdentifier lineIdentifier = new LineIdentifier(fileContent);
-    StringModifier fileContentModifier = new StringModifier(fileContent);
-    for (FixReplacement fixReplacement : sortedReplacements) {
-      Comment.Range range = fixReplacement.range;
-      try {
-        int startLineIndex = lineIdentifier.getStartIndexOfLine(range.startLine);
-        int startLineLength = lineIdentifier.getLengthOfLine(range.startLine);
-
-        int endLineIndex = lineIdentifier.getStartIndexOfLine(range.endLine);
-        int endLineLength = lineIdentifier.getLengthOfLine(range.endLine);
-
-        if (range.startChar > startLineLength || range.endChar > endLineLength) {
-          throw new ResourceConflictException(
-              String.format(
-                  "Range %s refers to a non-existent offset (start line length: %s,"
-                      + " end line length: %s)",
-                  toString(range), startLineLength, endLineLength));
-        }
-
-        int startIndex = startLineIndex + range.startChar;
-        int endIndex = endLineIndex + range.endChar;
-        fileContentModifier.replace(startIndex, endIndex, fixReplacement.replacement);
-      } catch (StringIndexOutOfBoundsException e) {
-        // Most of the StringIndexOutOfBoundsException should never occur because we reject fix
-        // replacements for invalid ranges. However, we can't cover all cases for efficiency
-        // reasons. For instance, we don't determine the number of lines in a file. That's why we
-        // need to map this exception and thus provide a meaningful error.
-        throw new ResourceConflictException(
-            String.format("Cannot apply fix replacement for range %s", toString(range)), e);
-      }
-    }
-    return fileContentModifier.getResult();
-  }
-
-  private static String toString(Comment.Range range) {
-    return String.format(
-        "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar);
-  }
 }
diff --git a/java/com/google/gerrit/server/fixes/LineIdentifier.java b/java/com/google/gerrit/server/fixes/LineIdentifier.java
deleted file mode 100644
index 3d09c34..0000000
--- a/java/com/google/gerrit/server/fixes/LineIdentifier.java
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.fixes;
-
-import static java.util.Objects.requireNonNull;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * An identifier of lines in a string. Lines are sequences of characters which are separated by any
- * Unicode linebreak sequence as defined by the regular expression {@code \R}. If data for several
- * lines is requested, calls which are ordered according to ascending line numbers are the most
- * efficient.
- */
-class LineIdentifier {
-
-  private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
-  private final Matcher lineSeparatorMatcher;
-
-  private int nextLineNumber;
-  private int nextLineStartIndex;
-  private int currentLineStartIndex;
-  private int currentLineEndIndex;
-
-  LineIdentifier(String string) {
-    requireNonNull(string);
-    lineSeparatorMatcher = LINE_SEPARATOR_PATTERN.matcher(string);
-    reset();
-  }
-
-  /**
-   * Returns the start index of the indicated line within the given string. Start indices are
-   * zero-based while line numbers are one-based.
-   *
-   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
-   * increasing line number.
-   *
-   * @param lineNumber the line whose start index should be determined
-   * @return the start index of the line
-   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
-   *     the identified number of lines
-   */
-  public int getStartIndexOfLine(int lineNumber) {
-    findLine(lineNumber);
-    return currentLineStartIndex;
-  }
-
-  /**
-   * Returns the length of the indicated line in the given string. The character(s) used to separate
-   * lines aren't included in the count. Line numbers are one-based.
-   *
-   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
-   * increasing line number.
-   *
-   * @param lineNumber the line whose length should be determined
-   * @return the length of the line
-   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
-   *     the identified number of lines
-   */
-  public int getLengthOfLine(int lineNumber) {
-    findLine(lineNumber);
-    return currentLineEndIndex - currentLineStartIndex;
-  }
-
-  private void findLine(int targetLineNumber) {
-    if (targetLineNumber <= 0) {
-      throw new StringIndexOutOfBoundsException("Line number must be positive");
-    }
-    if (targetLineNumber < nextLineNumber) {
-      reset();
-    }
-    while (nextLineNumber < targetLineNumber + 1 && lineSeparatorMatcher.find()) {
-      currentLineStartIndex = nextLineStartIndex;
-      currentLineEndIndex = lineSeparatorMatcher.start();
-      nextLineStartIndex = lineSeparatorMatcher.end();
-      nextLineNumber++;
-    }
-
-    // End of string
-    if (nextLineNumber == targetLineNumber) {
-      currentLineStartIndex = nextLineStartIndex;
-      currentLineEndIndex = lineSeparatorMatcher.regionEnd();
-    }
-    if (nextLineNumber < targetLineNumber) {
-      throw new StringIndexOutOfBoundsException(
-          String.format("Line %d isn't available", targetLineNumber));
-    }
-  }
-
-  private void reset() {
-    nextLineNumber = 1;
-    nextLineStartIndex = 0;
-    currentLineStartIndex = 0;
-    currentLineEndIndex = 0;
-    lineSeparatorMatcher.reset();
-  }
-}
diff --git a/java/com/google/gerrit/server/fixes/testing/BUILD b/java/com/google/gerrit/server/fixes/testing/BUILD
new file mode 100644
index 0000000..765e8bf
--- /dev/null
+++ b/java/com/google/gerrit/server/fixes/testing/BUILD
@@ -0,0 +1,17 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "testing",
+    testonly = True,
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/jgit",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/truth",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/truth",
+    ],
+)
diff --git a/java/com/google/gerrit/server/fixes/testing/FixResultSubject.java b/java/com/google/gerrit/server/fixes/testing/FixResultSubject.java
new file mode 100644
index 0000000..21f96cd
--- /dev/null
+++ b/java/com/google/gerrit/server/fixes/testing/FixResultSubject.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.gitEdits;
+import static com.google.gerrit.truth.ListSubject.elements;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import com.google.gerrit.truth.ListSubject;
+import org.eclipse.jgit.diff.Edit;
+
+public class FixResultSubject extends Subject {
+  public static FixResultSubject assertThat(FixResult fixResult) {
+    return assertAbout(FixResultSubject::new).that(fixResult);
+  }
+
+  private final FixResult fixResult;
+
+  private FixResultSubject(FailureMetadata failureMetadata, FixResult fixResult) {
+    super(failureMetadata, fixResult);
+    this.fixResult = fixResult;
+  }
+
+  public StringSubject text() {
+    isNotNull();
+    return check("text").that(fixResult.text.getString(0, fixResult.text.size(), false));
+  }
+
+  public ListSubject<GitEditSubject, Edit> edits() {
+    isNotNull();
+    return check("edits").about(elements()).thatCustom(fixResult.edits, gitEdits());
+  }
+}
diff --git a/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java b/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java
new file mode 100644
index 0000000..53b88b1
--- /dev/null
+++ b/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.truth.ListSubject.elements;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
+import com.google.gerrit.truth.ListSubject;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.Edit.Type;
+
+public class GitEditSubject extends Subject {
+
+  public static GitEditSubject assertThat(Edit edit) {
+    return assertAbout(gitEdits()).that(edit);
+  }
+
+  public static Subject.Factory<GitEditSubject, Edit> gitEdits() {
+    return GitEditSubject::new;
+  }
+
+  private final Edit edit;
+
+  private GitEditSubject(FailureMetadata failureMetadata, Edit edit) {
+    super(failureMetadata, edit);
+    this.edit = edit;
+  }
+
+  public void hasRegions(int beginA, int endA, int beginB, int endB) {
+    isNotNull();
+    check("beginA").that(edit.getBeginA()).isEqualTo(beginA);
+    check("endA").that(edit.getEndA()).isEqualTo(endA);
+    check("beginB").that(edit.getBeginB()).isEqualTo(beginB);
+    check("endB").that(edit.getEndB()).isEqualTo(endB);
+  }
+
+  public void hasType(Type type) {
+    isNotNull();
+    check("getType").that(edit.getType()).isEqualTo(type);
+  }
+
+  public void isInsert(int insertPos, int beginB, int insertedLength) {
+    isNotNull();
+    hasType(Type.INSERT);
+    hasRegions(insertPos, insertPos, beginB, beginB + insertedLength);
+  }
+
+  public void isDelete(int deletePos, int deletedLength, int posB) {
+    isNotNull();
+    hasType(Type.DELETE);
+    hasRegions(deletePos, deletePos + deletedLength, posB, posB);
+  }
+
+  public void isReplace(int originalPos, int originalLength, int newPos, int newLength) {
+    isNotNull();
+    hasType(Type.REPLACE);
+    hasRegions(originalPos, originalPos + originalLength, newPos, newPos + newLength);
+  }
+
+  public void isEmpty() {
+    isNotNull();
+    hasType(Type.EMPTY);
+  }
+
+  public ListSubject<GitEditSubject, Edit> internalEdits() {
+    isNotNull();
+    isInstanceOf(ReplaceEdit.class);
+    return check("internalEdits")
+        .about(elements())
+        .thatCustom(((ReplaceEdit) edit).getInternalEdits(), gitEdits());
+  }
+}
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 4473ab7..242c11b 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -47,6 +46,12 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Logic for banning commits from being uploaded.
+ *
+ * <p>Gerrit has a per-project list of commits that are forbidden to be pushed. This class reads and
+ * writes the banned commits list in {@code refs/meta/reject-commits}.
+ */
 @Singleton
 public class BanCommit {
   /**
@@ -91,9 +96,14 @@
     this.tz = gerritIdent.getTimeZone();
   }
 
+  /**
+   * Bans a list of commits from the given project.
+   *
+   * <p>The user must be specified, so it can be checked for the {@code BAN_COMMIT} permission.
+   */
   public BanCommitResult ban(
       Project.NameKey project, CurrentUser user, List<ObjectId> commitsToBan, String reason)
-      throws AuthException, LockFailureException, IOException, PermissionBackendException {
+      throws AuthException, IOException, PermissionBackendException {
     permissionBackend.user(user).project(project).check(ProjectPermission.BAN_COMMIT);
 
     final BanCommitResult result = new BanCommitResult();
diff --git a/java/com/google/gerrit/server/git/BanCommitResult.java b/java/com/google/gerrit/server/git/BanCommitResult.java
index 9fadae2..c78123e 100644
--- a/java/com/google/gerrit/server/git/BanCommitResult.java
+++ b/java/com/google/gerrit/server/git/BanCommitResult.java
@@ -18,6 +18,7 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
+/** The outcome of the {@link com.google.gerrit.server.git.BanCommit} operation. */
 public class BanCommitResult {
   private final List<ObjectId> newlyBannedCommits = new ArrayList<>(4);
   private final List<ObjectId> alreadyBannedCommits = new ArrayList<>(4);
diff --git a/java/com/google/gerrit/server/git/BranchOrderSection.java b/java/com/google/gerrit/server/git/BranchOrderSection.java
index 4c77b61..0266655 100644
--- a/java/com/google/gerrit/server/git/BranchOrderSection.java
+++ b/java/com/google/gerrit/server/git/BranchOrderSection.java
@@ -18,6 +18,13 @@
 import com.google.gerrit.entities.RefNames;
 import java.util.List;
 
+/**
+ * An ordering of branches by stability.
+ *
+ * <p>The REST API supports automatically checking if changes on development branches can be merged
+ * into stable branches. This is configured by the {@code branchOrder.branch} project setting. This
+ * class represents the ordered list of branches, by increasing stability.
+ */
 public class BranchOrderSection {
 
   /**
diff --git a/java/com/google/gerrit/server/git/ChangeReportFormatter.java b/java/com/google/gerrit/server/git/ChangeReportFormatter.java
index f897a1d..e0efaef 100644
--- a/java/com/google/gerrit/server/git/ChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/ChangeReportFormatter.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 
+/** Formatter for git command-line progress messages. */
 public interface ChangeReportFormatter {
   @AutoValue
   public abstract static class Input {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 476037b..818212c 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -14,19 +14,41 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Account.Id;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,6 +56,10 @@
 import java.sql.Timestamp;
 import java.text.MessageFormat;
 import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -47,14 +73,41 @@
 /** Static utilities for working with {@link RevCommit}s. */
 @Singleton
 public class CommitUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final GitRepositoryManager repoManager;
   private final Provider<PersonIdent> serverIdent;
+  private final Sequences seq;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final NotifyResolver notifyResolver;
+  private final RevertedSender.Factory revertedSenderFactory;
+  private final ChangeMessagesUtil cmUtil;
+  private final ChangeReverted changeReverted;
+  private final BatchUpdate.Factory updateFactory;
 
   @Inject
   CommitUtil(
-      GitRepositoryManager repoManager, @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      Sequences seq,
+      ApprovalsUtil approvalsUtil,
+      ChangeInserter.Factory changeInserterFactory,
+      NotifyResolver notifyResolver,
+      RevertedSender.Factory revertedSenderFactory,
+      ChangeMessagesUtil cmUtil,
+      ChangeReverted changeReverted,
+      BatchUpdate.Factory updateFactory) {
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
+    this.seq = seq;
+    this.approvalsUtil = approvalsUtil;
+    this.changeInserterFactory = changeInserterFactory;
+    this.notifyResolver = notifyResolver;
+    this.revertedSenderFactory = revertedSenderFactory;
+    this.cmUtil = cmUtil;
+    this.changeReverted = changeReverted;
+    this.updateFactory = updateFactory;
   }
 
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
@@ -81,49 +134,79 @@
   }
 
   /**
-   * Allows creating a revert commit.
+   * Allows creating a revert change.
    *
-   * @param message Commit message for the revert commit.
    * @param notes ChangeNotes of the change being reverted.
    * @param user Current User performing the revert.
+   * @param input the RevertInput entity for conducting the revert.
+   * @param timestamp timestamp for the created change.
    * @return ObjectId that represents the newly created commit.
-   * @throws ResourceConflictException Can't revert the initial commit.
-   * @throws IOException Thrown in case of I/O errors.
    */
-  public ObjectId createRevertCommit(String message, ChangeNotes notes, CurrentUser user)
-      throws ResourceConflictException, IOException {
-    message = Strings.emptyToNull(message);
+  public Change.Id createRevertChange(
+      ChangeNotes notes, CurrentUser user, RevertInput input, Timestamp timestamp)
+      throws RestApiException, UpdateException, ConfigInvalidException, IOException {
+    String message = Strings.emptyToNull(input.message);
 
-    Project.NameKey project = notes.getProjectName();
-    try (Repository git = repoManager.openRepository(project);
+    try (Repository git = repoManager.openRepository(notes.getProjectName());
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
-      return createRevertCommit(message, notes, user, null, TimeUtil.nowTs(), oi, revWalk);
+      ObjectId generatedChangeId = Change.generateChangeId();
+      ObjectId revCommit =
+          createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId);
+      return createRevertChangeFromCommit(
+          revCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git);
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(notes.getChangeId().toString(), e);
     }
   }
 
   /**
+   * Wrapper function for creating a revert Commit.
+   *
    * @param message Commit message for the revert commit.
    * @param notes ChangeNotes of the change being reverted.
    * @param user Current User performing the revert.
-   * @param generatedChangeId The changeId for the commit message, can be null since it is not
-   *     needed for commits, only for changes.
+   * @param ts Timestamp of creation for the commit.
+   * @return ObjectId that represents the newly created commit.
+   */
+  public ObjectId createRevertCommit(
+      String message, ChangeNotes notes, CurrentUser user, Timestamp ts)
+      throws RestApiException, IOException {
+
+    try (Repository git = repoManager.openRepository(notes.getProjectName());
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      return createRevertCommit(message, notes, user, ts, oi, revWalk, null);
+    } catch (RepositoryNotFoundException e) {
+      throw new ResourceNotFoundException(notes.getProjectName().toString(), e);
+    }
+  }
+
+  /**
+   * Creates a revert commit.
+   *
+   * @param message Commit message for the revert commit.
+   * @param notes ChangeNotes of the change being reverted.
+   * @param user Current User performing the revert.
    * @param ts Timestamp of creation for the commit.
    * @param oi ObjectInserter for inserting the newly created commit.
    * @param revWalk Used for parsing the original commit.
+   * @param generatedChangeId The changeId for the commit message, can be null since it is not
+   *     needed for commits, only for changes.
    * @return ObjectId that represents the newly created commit.
    * @throws ResourceConflictException Can't revert the initial commit.
    * @throws IOException Thrown in case of I/O errors.
    */
-  public ObjectId createRevertCommit(
+  private ObjectId createRevertCommit(
       String message,
       ChangeNotes notes,
       CurrentUser user,
-      @Nullable ObjectId generatedChangeId,
       Timestamp ts,
       ObjectInserter oi,
-      RevWalk revWalk)
+      RevWalk revWalk,
+      @Nullable ObjectId generatedChangeId)
       throws ResourceConflictException, IOException {
 
     PatchSet patch = notes.getCurrentPatchSet();
@@ -146,12 +229,14 @@
     revertCommitBuilder.setCommitter(authorIdent);
 
     Change changeToRevert = notes.getChange();
+    String subject = changeToRevert.getSubject();
+    if (subject.length() > 63) {
+      subject = subject.substring(0, 59) + "...";
+    }
     if (message == null) {
       message =
           MessageFormat.format(
-              ChangeMessages.get().revertChangeDefaultMessage,
-              changeToRevert.getSubject(),
-              patch.commitId().name());
+              ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
     }
     if (generatedChangeId != null) {
       revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
@@ -160,4 +245,94 @@
     oi.flush();
     return id;
   }
+
+  private Change.Id createRevertChangeFromCommit(
+      ObjectId revertCommitId,
+      RevertInput input,
+      ChangeNotes notes,
+      CurrentUser user,
+      @Nullable ObjectId generatedChangeId,
+      Timestamp ts,
+      ObjectInserter oi,
+      RevWalk revWalk,
+      Repository git)
+      throws IOException, RestApiException, UpdateException, ConfigInvalidException {
+    RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
+    Change changeToRevert = notes.getChange();
+    Change.Id changeId = Change.id(seq.nextChangeId());
+    NotifyResolver.Result notify =
+        notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
+
+    ChangeInserter ins =
+        changeInserterFactory
+            .create(changeId, revertCommit, notes.getChange().getDest().branch())
+            .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
+    ins.setMessage("Uploaded patch set 1.");
+
+    ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
+
+    Set<Id> reviewers = new HashSet<>();
+    reviewers.add(changeToRevert.getOwner());
+    reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
+    reviewers.remove(user.getAccountId());
+    Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
+    ccs.remove(user.getAccountId());
+    ins.setReviewersAndCcs(reviewers, ccs);
+    ins.setRevertOf(notes.getChangeId());
+
+    try (BatchUpdate bu = updateFactory.create(notes.getProjectName(), user, ts)) {
+      bu.setRepository(git, revWalk, oi);
+      bu.setNotify(notify);
+      bu.insertChange(ins);
+      bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
+      bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(generatedChangeId));
+      bu.execute();
+    }
+    return changeId;
+  }
+
+  private class NotifyOp implements BatchUpdateOp {
+    private final Change change;
+    private final ChangeInserter ins;
+
+    NotifyOp(Change change, ChangeInserter ins) {
+      this.change = change;
+      this.ins = ins;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws Exception {
+      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
+      try {
+        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        cm.setFrom(ctx.getAccountId());
+        cm.setNotify(ctx.getNotify(change.getId()));
+        cm.send();
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot send email for revert change %s", change.getId());
+      }
+    }
+  }
+
+  private class PostRevertedMessageOp implements BatchUpdateOp {
+    private final ObjectId computedChangeId;
+
+    PostRevertedMessageOp(ObjectId computedChangeId) {
+      this.computedChangeId = computedChangeId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      Change change = ctx.getChange();
+      PatchSet.Id patchSetId = change.currentPatchSetId();
+      ChangeMessage changeMessage =
+          ChangeMessagesUtil.newMessage(
+              ctx,
+              "Created a revert of this change as I" + computedChangeId.name(),
+              ChangeMessagesUtil.TAG_REVERT);
+      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      return true;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index 5866c57..4f6094e 100644
--- a/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -22,7 +22,7 @@
 import com.google.inject.Inject;
 import java.util.Optional;
 
-/** Print a change description for use in git command-line progress. */
+/** Default formatter for change descriptions for use in git command-line progress. */
 public class DefaultChangeReportFormatter implements ChangeReportFormatter {
   private static final int SUBJECT_MAX_LENGTH = 80;
   private static final String SUBJECT_CROP_APPENDIX = "...";
diff --git a/java/com/google/gerrit/server/git/DefaultQueueOp.java b/java/com/google/gerrit/server/git/DefaultQueueOp.java
index b30acfa..9fb1a9b 100644
--- a/java/com/google/gerrit/server/git/DefaultQueueOp.java
+++ b/java/com/google/gerrit/server/git/DefaultQueueOp.java
@@ -17,6 +17,10 @@
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
+/**
+ * Wrapper class so a Runnable can schedule itself onto the Gerrit Workqueue. Subclasses must
+ * implement the {@code run} method.
+ */
 public abstract class DefaultQueueOp implements Runnable {
   private final WorkQueue workQueue;
 
diff --git a/java/com/google/gerrit/server/git/DelegateRefDatabase.java b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
index 34dd6a9..decae05 100644
--- a/java/com/google/gerrit/server/git/DelegateRefDatabase.java
+++ b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
@@ -17,6 +17,9 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.RefRename;
@@ -24,7 +27,8 @@
 import org.eclipse.jgit.lib.Repository;
 
 /**
- * Wrapper around {@link RefDatabase} that delegates all calls to the wrapped {@link RefDatabase}.
+ * Wrapper around {@link RefDatabase} that delegates all calls to the wrapped {@link Repository}'s
+ * {@link RefDatabase}.
  */
 public class DelegateRefDatabase extends RefDatabase {
 
@@ -41,7 +45,7 @@
 
   @Override
   public void close() {
-    delegate.close();
+    delegate.getRefDatabase().close();
   }
 
   @Override
@@ -71,6 +75,12 @@
   }
 
   @Override
+  @NonNull
+  public Set<Ref> getTipsWithSha1(ObjectId id) throws IOException {
+    return delegate.getRefDatabase().getTipsWithSha1(id);
+  }
+
+  @Override
   public List<Ref> getAdditionalRefs() throws IOException {
     return delegate.getRefDatabase().getAdditionalRefs();
   }
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 800490d..b61488b 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -81,7 +81,8 @@
   @SuppressWarnings("rawtypes")
   private static BaseRepositoryBuilder toBuilder(Repository repo) {
     if (!repo.isBare()) {
-      throw new IllegalArgumentException("non-bare repository is not supported");
+      throw new IllegalArgumentException(
+          "non-bare repository is not supported: " + repo.getIdentifier());
     }
 
     return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index 090d439..9b52f48 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
@@ -37,6 +38,7 @@
 import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.storage.pack.PackConfig;
 
+/** Serial execution of GC on a list of repositories. */
 public class GarbageCollection {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -69,8 +71,9 @@
     return run(projectNames, gcConfig.isAggressive(), writer);
   }
 
+  /** Runs GC on the given projects, serially. Progress is written to writer if non-null. */
   public GarbageCollectionResult run(
-      List<Project.NameKey> projectNames, boolean aggressive, PrintWriter writer) {
+      List<Project.NameKey> projectNames, boolean aggressive, @Nullable PrintWriter writer) {
     GarbageCollectionResult result = new GarbageCollectionResult();
     Set<Project.NameKey> projectsToGc = gcQueue.addAll(projectNames);
     for (Project.NameKey projectName :
diff --git a/java/com/google/gerrit/server/git/GarbageCollectionQueue.java b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
index e3a923b..5df9ab5 100644
--- a/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
+++ b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
@@ -21,6 +21,7 @@
 import java.util.HashSet;
 import java.util.Set;
 
+/** A thread-safe list of projects scheduled for GC. */
 @Singleton
 public class GarbageCollectionQueue {
   private final Set<Project.NameKey> projectsScheduledForGc = new HashSet<>();
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
index 6e8bab1..354b69f 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
@@ -18,6 +18,10 @@
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.inject.Inject;
 
+/**
+ * Module to install {@link MultiBaseLocalDiskRepositoryManager} rather than {@link
+ * LocalDiskRepositoryManager} if needed.
+ */
 public class GitRepositoryManagerModule extends LifecycleModule {
 
   private final RepositoryConfig repoConfig;
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index c284f7f4..1f0dcd4 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -23,16 +23,18 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multimaps;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.receive.ReceivePackRefCache;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.Collection;
 import java.util.Deque;
@@ -87,24 +89,29 @@
     return rsrc.getPatchSet().groups();
   }
 
-  private interface Lookup {
+  interface Lookup {
     List<String> lookup(PatchSet.Id psId);
   }
 
-  private final ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha;
+  private final ReceivePackRefCache receivePackRefCache;
   private final ListMultimap<ObjectId, String> groups;
   private final SetMultimap<String, String> groupAliases;
   private final Lookup groupLookup;
 
   private boolean done;
 
+  /**
+   * Returns a new {@link GroupCollector} instance.
+   *
+   * @see GroupCollector for what this class does.
+   */
   public static GroupCollector create(
-      ListMultimap<ObjectId, Ref> changeRefsById,
+      ReceivePackRefCache receivePackRefCache,
       PatchSetUtil psUtil,
       ChangeNotes.Factory notesFactory,
       Project.NameKey project) {
     return new GroupCollector(
-        transformRefs(changeRefsById),
+        receivePackRefCache,
         psId -> {
           // TODO(dborowitz): Reuse open repository from caller.
           ChangeNotes notes = notesFactory.createChecked(project, psId.changeId());
@@ -113,31 +120,32 @@
         });
   }
 
-  private GroupCollector(ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha, Lookup groupLookup) {
-    this.patchSetsBySha = patchSetsBySha;
+  /**
+   * Returns a new {@link GroupCollector} instance.
+   *
+   * <p>Used in production code by using {@link com.google.gerrit.server.notedb.ChangeNotes.Factory}
+   * to get a group SHA1 (40 bytes string representation) from a {@link
+   * com.google.gerrit.entities.PatchSet.Id}. Unit tests use this method directly by passing their
+   * own lookup function.
+   *
+   * @see GroupCollector for what this class does.
+   */
+  @VisibleForTesting
+  GroupCollector(ReceivePackRefCache receivePackRefCache, Lookup groupLookup) {
+    this.receivePackRefCache = receivePackRefCache;
     this.groupLookup = groupLookup;
     groups = MultimapBuilder.hashKeys().arrayListValues().build();
     groupAliases = MultimapBuilder.hashKeys().hashSetValues().build();
   }
 
-  private static ListMultimap<ObjectId, PatchSet.Id> transformRefs(
-      ListMultimap<ObjectId, Ref> refs) {
-    return Multimaps.transformValues(refs, r -> PatchSet.Id.fromRef(r.getName()));
-  }
-
-  @VisibleForTesting
-  GroupCollector(
-      ListMultimap<ObjectId, PatchSet.Id> patchSetsBySha,
-      ListMultimap<PatchSet.Id, String> groupLookup) {
-    this(
-        patchSetsBySha,
-        psId -> {
-          List<String> groups = groupLookup.get(psId);
-          return !groups.isEmpty() ? groups : null;
-        });
-  }
-
-  public void visit(RevCommit c) {
+  /**
+   * Process the given {@link RevCommit}. Callers must call {@link #visit(RevCommit)} on all commits
+   * between the current branch tip and the tip of a push, in reverse topo order (parents before
+   * children). Once all commits have been visited, call {@link #getGroups()} for the result.
+   *
+   * @see GroupCollector for what this class does.
+   */
+  public void visit(RevCommit c) throws IOException {
     checkState(!done, "visit() called after getGroups()");
     Set<RevCommit> interestingParents = getInterestingParents(c);
 
@@ -197,7 +205,10 @@
     }
   }
 
-  public SortedSetMultimap<ObjectId, String> getGroups() {
+  /**
+   * Returns the groups that got collected from visiting commits using {@link #visit(RevCommit)}.
+   */
+  public SortedSetMultimap<ObjectId, String> getGroups() throws IOException {
     done = true;
     SortedSetMultimap<ObjectId, String> result =
         MultimapBuilder.hashKeys(groups.keySet().size()).treeSetValues().build();
@@ -218,12 +229,13 @@
     return result;
   }
 
-  private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) {
+  private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && patchSetsBySha.containsKey(id);
+    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
   }
 
-  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates) {
+  private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
+      throws IOException {
     Set<String> actual = Sets.newTreeSet();
     Set<String> done = Sets.newHashSetWithExpectedSize(candidates.size());
     Set<String> seen = Sets.newHashSetWithExpectedSize(candidates.size());
@@ -258,16 +270,20 @@
     }
   }
 
-  private Iterable<String> resolveGroup(ObjectId forCommit, String group) {
+  private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      PatchSet.Id psId = Iterables.getFirst(patchSetsBySha.get(id), null);
-      if (psId != null) {
-        List<String> groups = groupLookup.lookup(psId);
-        // Group for existing patch set may be missing, e.g. if group has not
-        // been migrated yet.
-        if (groups != null && !groups.isEmpty()) {
-          return groups;
+      Ref ref =
+          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
+      if (ref != null) {
+        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+        if (psId != null) {
+          List<String> groups = groupLookup.lookup(psId);
+          // Group for existing patch set may be missing, e.g. if group has not
+          // been migrated yet.
+          if (groups != null && !groups.isEmpty()) {
+            return groups;
+          }
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 58899eb..801300e 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -265,7 +266,8 @@
       boolean ignoreIdenticalTree,
       boolean allowConflicts)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException {
+          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException,
+          InvalidMergeStrategyException {
 
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     m.setBase(originalCommit.getParent(parentIndex));
@@ -431,7 +433,8 @@
       PersonIdent committerIndent,
       String commitMsg,
       RevWalk rw)
-      throws IOException, MergeIdenticalTreeException, MergeConflictException {
+      throws IOException, MergeIdenticalTreeException, MergeConflictException,
+          InvalidMergeStrategyException {
 
     if (!MergeStrategy.THEIRS.getName().equals(mergeStrategy)
         && rw.isMergedInto(originalCommit, mergeTip)) {
@@ -745,7 +748,7 @@
       BranchNameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
-      throws IntegrationException {
+      throws IntegrationException, InvalidMergeStrategyException {
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
       if (m.merge(mergeTip, n)) {
@@ -867,7 +870,8 @@
         .collect(joining(",", "Merge changes ", merged.size() > 5 ? ", ..." : ""));
   }
 
-  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
+  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig)
+      throws InvalidMergeStrategyException {
     return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
   }
 
@@ -876,22 +880,32 @@
   }
 
   public static String mergeStrategyName(boolean useContentMerge, boolean useRecursiveMerge) {
+    String mergeStrategy;
+
     if (useContentMerge) {
       // Settings for this project allow us to try and automatically resolve
       // conflicts within files if needed. Use either the old resolve merger or
       // new recursive merger, and instruct to operate in core.
       if (useRecursiveMerge) {
-        return MergeStrategy.RECURSIVE.getName();
+        mergeStrategy = MergeStrategy.RECURSIVE.getName();
+      } else {
+        mergeStrategy = MergeStrategy.RESOLVE.getName();
       }
-      return MergeStrategy.RESOLVE.getName();
+    } else {
+      // No auto conflict resolving allowed. If any of the
+      // affected files was modified, merge will fail.
+      mergeStrategy = MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
     }
-    // No auto conflict resolving allowed. If any of the
-    // affected files was modified, merge will fail.
-    return MergeStrategy.SIMPLE_TWO_WAY_IN_CORE.getName();
+
+    logger.atFine().log(
+        "mergeStrategy = %s (useContentMerge = %s, useRecursiveMerge = %s)",
+        mergeStrategy, useContentMerge, useRecursiveMerge);
+    return mergeStrategy;
   }
 
   public static ThreeWayMerger newThreeWayMerger(
-      ObjectInserter inserter, Config repoConfig, String strategyName) {
+      ObjectInserter inserter, Config repoConfig, String strategyName)
+      throws InvalidMergeStrategyException {
     Merger m = newMerger(inserter, repoConfig, strategyName);
     checkArgument(
         m instanceof ThreeWayMerger,
@@ -900,9 +914,12 @@
     return (ThreeWayMerger) m;
   }
 
-  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
+  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName)
+      throws InvalidMergeStrategyException {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
-    checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
+    if (strategy == null) {
+      throw new InvalidMergeStrategyException(strategyName);
+    }
     return strategy.newMerger(
         new ObjectInserter.Filter() {
           @Override
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 858a55a..b272cba 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
@@ -44,6 +45,12 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Operation to close a change on push.
+ *
+ * <p>When we find a change corresponding to a commit that is pushed to a branch directly, we close
+ * the change. This class marks the change as merged, and sends out the email notification.
+ */
 public class MergedByPushOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -51,6 +58,7 @@
     MergedByPushOp create(
         RequestScopePropagator requestScopePropagator,
         PatchSet.Id psId,
+        @Assisted SubmissionId submissionId,
         @Assisted("refName") String refName,
         @Assisted("mergeResultRevId") String mergeResultRevId);
   }
@@ -64,6 +72,7 @@
   private final ChangeMerged changeMerged;
 
   private final PatchSet.Id psId;
+  private final SubmissionId submissionId;
   private final String refName;
   private final String mergeResultRevId;
 
@@ -83,6 +92,7 @@
       ChangeMerged changeMerged,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
+      @Assisted SubmissionId submissionId,
       @Assisted("refName") String refName,
       @Assisted("mergeResultRevId") String mergeResultRevId) {
     this.patchSetInfoFactory = patchSetInfoFactory;
@@ -92,6 +102,7 @@
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
     this.requestScopePropagator = requestScopePropagator;
+    this.submissionId = submissionId;
     this.psId = psId;
     this.refName = refName;
     this.mergeResultRevId = mergeResultRevId;
@@ -132,9 +143,10 @@
     }
     change.setCurrentPatchSet(info);
     change.setStatus(Change.Status.MERGED);
+    change.setSubmissionId(submissionId.toString());
     // we cannot reconstruct the submit records for when this change was
-    // submitted, this is why we must fix the status
-    update.fixStatus(Change.Status.MERGED);
+    // submitted, this is why we must fix the status and other details.
+    update.fixStatusToMerged(submissionId);
     update.setCurrentPatchSet();
     if (change.isWorkInProgress()) {
       change.setWorkInProgress(false);
diff --git a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index 32b5bb8..fd6506a 100644
--- a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -26,6 +26,12 @@
 import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * RepositoryManager that looks up repos stored across directories.
+ *
+ * <p>Each repository has a path configured in Gerrit server config, repository.NAME.basePath,
+ * indicating where the repo can be found
+ */
 @Singleton
 public class MultiBaseLocalDiskRepositoryManager extends LocalDiskRepositoryManager {
 
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index c68842d..b7dc2b3 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -15,23 +15,27 @@
 package com.google.gerrit.server.git;
 
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefRename;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -45,7 +49,6 @@
 
   private final PermissionBackend.ForProject forProject;
 
-  @Inject
   PermissionAwareReadOnlyRefDatabase(
       Repository delegateRepository, PermissionBackend.ForProject forProject) {
     super(delegateRepository);
@@ -74,10 +77,9 @@
       return null;
     }
 
-    Map<String, Ref> result;
+    Collection<Ref> result;
     try {
-      result =
-          forProject.filter(ImmutableMap.of(name, ref), getDelegate(), RefFilterOptions.defaults());
+      result = forProject.filter(ImmutableList.of(ref), getDelegate(), RefFilterOptions.defaults());
     } catch (PermissionBackendException e) {
       if (e.getCause() instanceof IOException) {
         throw (IOException) e.getCause();
@@ -90,48 +92,35 @@
 
     Preconditions.checkState(
         result.size() == 1, "Only one element expected, but was: " + result.size());
-    return Iterables.getOnlyElement(result.values());
+    return Iterables.getOnlyElement(result);
   }
 
-  @SuppressWarnings("deprecation")
   @Override
   public Map<String, Ref> getRefs(String prefix) throws IOException {
-    Map<String, Ref> refs = getDelegate().getRefDatabase().getRefs(prefix);
+    List<Ref> refs = getDelegate().getRefDatabase().getRefsByPrefix(prefix);
     if (refs.isEmpty()) {
-      return refs;
+      return Collections.emptyMap();
     }
 
-    Map<String, Ref> result;
+    Collection<Ref> result;
     try {
-      // The security filtering assumes to receive the same refMap, independently from the ref
-      // prefix offset
-      result =
-          forProject.filter(
-              prefixIndependentRefMap(prefix, refs), getDelegate(), RefFilterOptions.defaults());
+      result = forProject.filter(refs, getDelegate(), RefFilterOptions.defaults());
     } catch (PermissionBackendException e) {
       throw new IOException("", e);
     }
-    return applyPrefixRefMap(prefix, result);
+    return buildPrefixRefMap(prefix, result);
   }
 
-  private Map<String, Ref> prefixIndependentRefMap(String prefix, Map<String, Ref> refs) {
-    if (prefix.length() > 0) {
-      return refs.values().stream().collect(Collectors.toMap(Ref::getName, Function.identity()));
-    }
-
-    return refs;
-  }
-
-  private Map<String, Ref> applyPrefixRefMap(String prefix, Map<String, Ref> refs) {
+  private Map<String, Ref> buildPrefixRefMap(String prefix, Collection<Ref> refs) {
     int prefixSlashPos = prefix.lastIndexOf('/') + 1;
     if (prefixSlashPos > 0) {
-      return refs.values().stream()
+      return refs.stream()
           .collect(
               Collectors.toMap(
                   (Ref ref) -> ref.getName().substring(prefixSlashPos), Function.identity()));
     }
 
-    return refs;
+    return refs.stream().collect(toMap(Ref::getName, r -> r));
   }
 
   @Override
@@ -182,4 +171,17 @@
     }
     return null;
   }
+
+  @Override
+  @NonNull
+  public Set<Ref> getTipsWithSha1(ObjectId id) throws IOException {
+    Set<Ref> unfiltered = super.getTipsWithSha1(id);
+    Set<Ref> result = new HashSet<>(unfiltered.size());
+    for (Ref ref : unfiltered) {
+      if (exactRef(ref.getName()) != null) {
+        result.add(ref);
+      }
+    }
+    return result;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 4c0378a..f5d7037 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -27,6 +27,7 @@
 import java.util.List;
 import java.util.Map;
 
+/** (De)serializer for tab-delimited text files. */
 public class TabFile {
   @FunctionalInterface
   public interface Parser {
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 7038736..942baea 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PublishCommentsOp;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ReceiveCommitsExecutor;
@@ -103,6 +104,7 @@
       // Don't expose the binding for ReceiveCommits.Factory. All callers should
       // be using AsyncReceiveCommits.Factory instead.
       install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
+      install(new FactoryModuleBuilder().build(PublishCommentsOp.Factory.class));
       install(new FactoryModuleBuilder().build(BranchCommitValidator.Factory.class));
     }
 
@@ -310,7 +312,8 @@
 
     allRefsWatcher = new AllRefsWatcher();
     receivePack.setAdvertiseRefsHook(
-        ReceiveCommitsAdvertiseRefsHookChain.create(allRefsWatcher, queryProvider, projectName));
+        ReceiveCommitsAdvertiseRefsHookChain.create(
+            allRefsWatcher, queryProvider, projectName, user.getAccountId()));
     resultChangeIds = new ResultChangeIds();
     receiveCommits =
         factory.create(
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index d89bb63..766a835 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -2,15 +2,20 @@
 
 java_library(
     name = "receive",
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["ReceivePackRefCache.java"],
+    ),
     visibility = ["//visibility:public"],
     deps = [
+        ":ref_cache",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
@@ -26,3 +31,14 @@
         "//lib/guice:guice-assistedinject",
     ],
 )
+
+java_library(
+    name = "ref_cache",
+    srcs = ["ReceivePackRefCache.java"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//lib:guava",
+        "//lib:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index c6c9b39..4b6a4cd 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -43,7 +43,6 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
@@ -74,6 +73,7 @@
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -92,7 +92,9 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentSource;
 import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -102,6 +104,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.PublishCommentsOp;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
@@ -124,6 +127,8 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.validators.CommentCountValidator;
+import com.google.gerrit.server.git.validators.CommentSizeValidator;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.RefOperationValidationException;
 import com.google.gerrit.server.git.validators.RefOperationValidators;
@@ -166,8 +171,6 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.Action;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
@@ -295,7 +298,7 @@
     } else if ((e instanceof ExecutionException) && (e.getCause() instanceof RestApiException)) {
       return (RestApiException) e.getCause();
     }
-    return new RestApiException("Error inserting change/patchset", e);
+    return RestApiException.wrap("Error inserting change/patchset", e);
   }
 
   // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
@@ -333,6 +336,7 @@
   private final RefOperationValidators.Factory refValidatorsFactory;
   private final ReplaceOp.Factory replaceOpFactory;
   private final PluginSetContext<RequestListener> requestListeners;
+  private final PublishCommentsOp.Factory publishCommentsOp;
   private final RetryHelper retryHelper;
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
@@ -343,7 +347,6 @@
   private final SetPrivateOp.Factory setPrivateOpFactory;
 
   // Assisted injected fields.
-  private final AllRefsWatcher allRefsWatcher;
   private final ProjectState projectState;
   private final IdentifiedUser user;
   private final ReceivePack receivePack;
@@ -363,12 +366,9 @@
   private final ListMultimap<String, String> errors;
 
   private final ListMultimap<String, String> pushOptions;
+  private final ReceivePackRefCache receivePackRefCache;
   private final Map<Change.Id, ReplaceRequest> replaceByChange;
 
-  // Collections lazily populated during processing.
-  private ListMultimap<Change.Id, Ref> refsByChange;
-  private ListMultimap<ObjectId, Ref> refsById;
-
   // Other settings populated during processing.
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
@@ -409,6 +409,7 @@
       Provider<InternalChangeQuery> queryProvider,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeOpRepoManager> ormProvider,
+      PublishCommentsOp.Factory publishCommentsOp,
       ReceiveConfig receiveConfig,
       RefOperationValidators.Factory refValidatorsFactory,
       ReplaceOp.Factory replaceOpFactory,
@@ -455,6 +456,7 @@
     this.projectCache = projectCache;
     this.psUtil = psUtil;
     this.performanceLoggers = performanceLoggers;
+    this.publishCommentsOp = publishCommentsOp;
     this.queryProvider = queryProvider;
     this.receiveConfig = receiveConfig;
     this.refValidatorsFactory = refValidatorsFactory;
@@ -469,7 +471,6 @@
     this.setPrivateOpFactory = setPrivateOpFactory;
 
     // Assisted injected fields.
-    this.allRefsWatcher = allRefsWatcher;
     this.projectState = projectState;
     this.user = user;
     this.receivePack = rp;
@@ -501,6 +502,13 @@
     this.messageSender = messageSender != null ? messageSender : new ReceivePackMessageSender();
     this.resultChangeIds = resultChangeIds;
     this.loggingTags = ImmutableMap.of();
+
+    // TODO(hiesel): Make this decision implicit once vetted
+    boolean useRefCache = config.getBoolean("receive", "enableInMemoryRefCache", true);
+    receivePackRefCache =
+        useRefCache
+            ? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs())
+            : ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase());
   }
 
   void init() {
@@ -653,17 +661,27 @@
     Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
 
     List<CreateRequest> newChanges = Collections.emptyList();
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+    try {
+      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+        try {
+          newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+        } catch (IOException e) {
+          logger.atSevere().withCause(e).log(
+              "Failed to select new changes in %s", project.getName());
+          return;
+        }
+      }
+
+      // Commit validation has already happened, so any changes without Change-Id are for the
+      // deprecated feature.
+      warnAboutMissingChangeId(newChanges);
+      preparePatchSetsForReplace(newChanges);
+      insertChangesAndPatchSets(newChanges, replaceProgress);
+    } finally {
+      newProgress.end();
+      replaceProgress.end();
     }
 
-    // Commit validation has already happened, so any changes without Change-Id are for the
-    // deprecated feature.
-    warnAboutMissingChangeId(newChanges);
-    preparePatchSetsForReplace(newChanges);
-    insertChangesAndPatchSets(newChanges, replaceProgress);
-    newProgress.end();
-    replaceProgress.end();
     queueSuccessMessages(newChanges);
 
     logger.atFine().log(
@@ -895,10 +913,15 @@
 
         logger.atFine().log("Adding %d replace requests", newChanges.size());
         for (ReplaceRequest replace : replaceByChange.values()) {
+          replace.addOps(bu, replaceProgress);
           if (magicBranch != null) {
             bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
+            if (magicBranch.shouldPublishComments()) {
+              bu.addOp(
+                  replace.notes.getChangeId(),
+                  publishCommentsOp.create(replace.psId, project.getNameKey()));
+            }
           }
-          replace.addOps(bu, replaceProgress);
         }
 
         logger.atFine().log("Adding %d create requests", newChanges.size());
@@ -1418,15 +1441,21 @@
     private final ProjectState projectState;
     private final boolean defaultPublishComments;
 
-    boolean deprecatedTopicSeen;
     final ReceiveCommand cmd;
     final LabelTypes labelTypes;
     /**
-     * Result of running {@link CommentValidator}-s on drafts that are published with the commit
-     * (which happens iff {@code --publish-comments} is set). Remains {@code true} if none are
-     * installed.
+     * Draft comments are published with the commit iff {@code --publish-comments} is set. All
+     * drafts are withheld (overriding the option) if at least one of the following conditions are
+     * met:
+     *
+     * <ul>
+     *   <li>Installed {@link CommentValidator} plugins reject one or more draft comments.
+     *   <li>One or more comments exceed the maximum comment size (see {@link
+     *       CommentSizeValidator}).
+     *   <li>The maximum number of comments would be exceeded (see {@link CommentCountValidator}).
+     * </ul>
      */
-    private boolean commentsValid = true;
+    private boolean withholdComments = false;
 
     BranchNameKey dest;
     PermissionBackend.ForRef perm;
@@ -1580,7 +1609,6 @@
         IdentifiedUser user, ProjectState projectState, ReceiveCommand cmd, LabelTypes labelTypes) {
       this.user = user;
       this.projectState = projectState;
-      this.deprecatedTopicSeen = false;
       this.cmd = cmd;
       this.labelTypes = labelTypes;
       GeneralPreferencesInfo prefs = user.state().generalPreferences();
@@ -1624,18 +1652,19 @@
           .collect(toImmutableSet());
     }
 
-    void setCommentsValid(boolean commentsValid) {
-      this.commentsValid = commentsValid;
+    void setWithholdComments(boolean withholdComments) {
+      this.withholdComments = withholdComments;
     }
 
     boolean shouldPublishComments() {
-      if (!commentsValid) {
+      if (withholdComments) {
         // Validation messages of type WARNING have already been added, now withhold the comments.
         return false;
       }
       if (publishComments) {
         return true;
-      } else if (noPublishComments) {
+      }
+      if (noPublishComments) {
         return false;
       }
       return defaultPublishComments;
@@ -1644,8 +1673,7 @@
     /**
      * returns the destination ref of the magic branch, and populates options in the cmdLineParser.
      */
-    String parse(Repository repo, Set<String> refs, ListMultimap<String, String> pushOptions)
-        throws CmdLineException {
+    String parse(ListMultimap<String, String> pushOptions) throws CmdLineException {
       String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
 
       ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
@@ -1667,28 +1695,7 @@
       if (!options.isEmpty()) {
         cmdLineParser.parseOptionMap(options);
       }
-
-      // We accept refs/for/BRANCHNAME/TOPIC. Since we don't know
-      // for sure where the branch ends and the topic starts, look
-      // backward for a split that works. This behavior is deprecated.
-      String head = readHEAD(repo);
-      int split = ref.length();
-      for (; ; ) {
-        String name = ref.substring(0, split);
-        if (refs.contains(name) || name.equals(head)) {
-          break;
-        }
-
-        split = name.lastIndexOf('/', split - 1);
-        if (split <= Constants.R_REFS.length()) {
-          return ref;
-        }
-      }
-      if (split < ref.length()) {
-        topic = Strings.emptyToNull(ref.substring(split + 1));
-        deprecatedTopicSeen = true;
-      }
-      return ref.substring(0, split);
+      return ref;
     }
 
     public boolean shouldSetWorkInProgressOnNewChanges() {
@@ -1734,7 +1741,7 @@
    *
    * <p>Assumes we are handling a magic branch here.
    */
-  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
+  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException, IOException {
     try (TraceTimer traceTimer = newTimer("parseMagicBranch")) {
       logger.atFine().log("Found magic branch %s", cmd.getRefName());
       MagicBranchInput magicBranch = new MagicBranchInput(user, projectState, cmd, labelTypes);
@@ -1743,7 +1750,7 @@
       magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
       try {
-        ref = magicBranch.parse(repo, receivePack.getAdvertisedRefs().keySet(), pushOptions);
+        ref = magicBranch.parse(pushOptions);
       } catch (CmdLineException e) {
         if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
           logger.atFine().log("Invalid branch syntax");
@@ -1775,7 +1782,7 @@
       // review to these branches is allowed even if the branch does not exist yet. This allows to
       // push initial code for review to an empty repository and to review an initial project
       // configuration.
-      if (!receivePack.getAdvertisedRefs().containsKey(ref)
+      if (receivePackRefCache.exactRef(ref) == null
           && !ref.equals(readHEAD(repo))
           && !ref.equals(RefNames.REFS_CONFIG)) {
         logger.atFine().log("Ref %s not found", ref);
@@ -1850,11 +1857,12 @@
             reject(cmd, "cannot use merged with base");
             return;
           }
-          RevCommit branchTip = readBranchTip(magicBranch.dest);
-          if (branchTip == null) {
+          Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
+          if (refTip == null) {
             reject(cmd, magicBranch.dest.branch() + " not found");
             return;
           }
+          RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId());
           if (!walk.isMergedInto(tip, branchTip)) {
             reject(cmd, "not merged into branch");
             return;
@@ -1891,8 +1899,9 @@
             }
           }
         } else if (newChangeForAllNotInTarget) {
-          RevCommit branchTip = readBranchTip(magicBranch.dest);
-          if (branchTip != null) {
+          Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
+          if (refTip != null) {
+            RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId());
             magicBranch.baseCommit = Collections.singletonList(branchTip);
             logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
           } else {
@@ -1916,13 +1925,6 @@
         return;
       }
 
-      if (magicBranch.deprecatedTopicSeen) {
-        messages.add(
-            new ValidationMessage(
-                "WARNING: deprecated topic syntax. Use -o topic=TOPIC instead", false));
-        logger.atInfo().log("deprecated topic push seen for project %s", project.getName());
-      }
-
       if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
         this.magicBranch = magicBranch;
         this.resultChangeIds.setMagicPush(true);
@@ -1939,7 +1941,7 @@
         newTimer("validateConnected", Metadata.builder().branchName(dest.branch()))) {
       RevWalk walk = receivePack.getRevWalk();
       try {
-        Ref targetRef = receivePack.getAdvertisedRefs().get(dest.branch());
+        Ref targetRef = receivePackRefCache.exactRef(dest.branch());
         if (targetRef == null || targetRef.getObjectId() == null) {
           // The destination branch does not yet exist. Assume the
           // history being sent for review will start it and thus
@@ -1986,14 +1988,6 @@
     }
   }
 
-  private RevCommit readBranchTip(BranchNameKey branch) throws IOException {
-    Ref r = allRefs().get(branch.branch());
-    if (r == null) {
-      return null;
-    }
-    return receivePack.getRevWalk().parseCommit(r.getObjectId());
-  }
-
   /**
    * Update an existing change. If draft comments are to be published, these are validated and may
    * be withheld.
@@ -2001,7 +1995,8 @@
    * @return True if the command succeeded, false if it was rejected.
    */
   private boolean requestReplaceAndValidateComments(
-      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
+      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit)
+      throws IOException {
     try (TraceTimer traceTimer = newTimer("requestReplaceAndValidateComments")) {
       if (change.isClosed()) {
         reject(
@@ -2026,14 +2021,18 @@
                 .map(
                     comment ->
                         CommentForValidation.create(
+                            CommentSource.HUMAN,
                             comment.lineNbr > 0
                                 ? CommentType.INLINE_COMMENT
                                 : CommentType.FILE_COMMENT,
-                            comment.message))
+                            comment.message,
+                            comment.message.length()))
                 .collect(toImmutableList());
+        CommentValidationContext ctx =
+            CommentValidationContext.create(change.getChangeId(), change.getProject().get());
         ImmutableList<CommentValidationFailure> commentValidationFailures =
-            PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
-        magicBranch.setCommentsValid(commentValidationFailures.isEmpty());
+            PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
+        magicBranch.setWithholdComments(!commentValidationFailures.isEmpty());
         commentValidationFailures.forEach(
             failure ->
                 addMessage(
@@ -2063,14 +2062,14 @@
     }
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress) {
+  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
+      throws IOException {
     try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
       logger.atFine().log("Finding new and replaced changes");
       List<CreateRequest> newChanges = new ArrayList<>();
 
-      ListMultimap<ObjectId, Ref> existing = changeRefsById();
       GroupCollector groupCollector =
-          GroupCollector.create(changeRefsById(), psUtil, notesFactory, project.getNameKey());
+          GroupCollector.create(receivePackRefCache, psUtil, notesFactory, project.getNameKey());
 
       BranchCommitValidator validator =
           commitValidatorFactory.create(projectState, magicBranch.dest, user);
@@ -2111,7 +2110,8 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs = existing.get(c);
+          Collection<Ref> existingRefs =
+              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
@@ -2275,7 +2275,8 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(existing.get(p.commit))) {
+            if (foundInExistingRef(
+                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2382,7 +2383,7 @@
       for (RevCommit c : magicBranch.baseCommit) {
         receivePack.getRevWalk().markUninteresting(c);
       }
-      Ref targetRef = allRefs().get(magicBranch.dest.branch());
+      Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch());
       if (targetRef != null) {
         logger.atFine().log(
             "Marking target ref %s (%s) uninteresting",
@@ -2397,7 +2398,7 @@
   private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
     try (TraceTimer traceTimer = newTimer("rejectImplicitMerges")) {
       if (!mergedParents.isEmpty()) {
-        Ref targetRef = allRefs().get(magicBranch.dest.branch());
+        Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch());
         if (targetRef != null) {
           RevWalk rw = receivePack.getRevWalk();
           RevCommit tip = rw.parseCommit(targetRef.getObjectId());
@@ -2432,13 +2433,15 @@
 
   // Mark all branch tips as uninteresting in the given revwalk,
   // so we get only the new commits when walking rw.
-  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
+  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) throws IOException {
     try (TraceTimer traceTimer =
         newTimer("markHeadsAsUninteresting", Metadata.builder().branchName(forRef))) {
       int i = 0;
-      for (Ref ref : allRefs().values()) {
-        if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
-            && ref.getObjectId() != null) {
+      for (Ref ref :
+          Iterables.concat(
+              receivePackRefCache.byPrefix(R_HEADS),
+              Collections.singletonList(receivePackRefCache.exactRef(forRef)))) {
+        if (ref != null && ref.getObjectId() != null) {
           try {
             rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
             i++;
@@ -2703,7 +2706,8 @@
     ReplaceOp replaceOp;
 
     ReplaceRequest(
-        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
+        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto)
+        throws IOException {
       this.ontoChange = toChange;
       this.newCommitId = newCommit.copy();
       this.inputCommand = requireNonNull(cmd);
@@ -2715,11 +2719,12 @@
         revCommit = null;
       }
       revisions = HashBiMap.create();
-      for (Ref ref : refs(toChange)) {
+      for (Ref ref : receivePackRefCache.byPrefix(RefNames.changeRefPrefix(toChange))) {
         try {
-          revisions.forcePut(
-              receivePack.getRevWalk().parseCommit(ref.getObjectId()),
-              PatchSet.Id.fromRef(ref.getName()));
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          if (psId != null) {
+            revisions.forcePut(receivePack.getRevWalk().parseCommit(ref.getObjectId()), psId);
+          }
         } catch (IOException err) {
           logger.atWarning().withCause(err).log(
               "Project %s contains invalid change ref %s", project.getName(), ref.getName());
@@ -2785,6 +2790,16 @@
         Change change = notes.getChange();
         priorPatchSet = change.currentPatchSetId();
         if (!revisions.containsValue(priorPatchSet)) {
+          logger.atWarning().log(
+              "Change %d is missing revision for patch set %s"
+                  + " (it has revisions for these patch sets: %s)",
+              change.getChangeId(),
+              priorPatchSet.getId(),
+              Iterables.toString(
+                  revisions.values().stream()
+                      .limit(100) // Enough for "normal" changes.
+                      .map(PatchSet.Id::getId)
+                      .collect(Collectors.toList())));
           reject(inputCommand, "change " + ontoChange + " missing revisions");
           return false;
         }
@@ -2812,11 +2827,16 @@
           return false;
         }
 
-        for (Ref r : receivePack.getRepository().getRefDatabase().getRefsByPrefix("refs/changes")) {
-          if (r.getObjectId().equals(newCommit)) {
-            reject(inputCommand, "commit already exists (in the project)");
-            return false;
-          }
+        List<Ref> existingChangesWithSameCommit =
+            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
+        if (!existingChangesWithSameCommit.isEmpty()) {
+          // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
+          //  without the option to turn that off.
+          reject(
+              inputCommand,
+              "commit already exists (in the project): "
+                  + existingChangesWithSameCommit.get(0).getName());
+          return false;
         }
 
         try (TraceTimer traceTimer2 = newTimer("validateNewPatchSetNoteDb#isMergedInto")) {
@@ -2950,14 +2970,20 @@
     private void newPatchSet() throws IOException {
       try (TraceTimer traceTimer = newTimer("newPatchSet")) {
         RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
-        psId =
-            ChangeUtil.nextPatchSetIdFromAllRefsMap(
-                allRefs(), notes.getChange().currentPatchSetId());
+        psId = nextPatchSetId(notes.getChange().currentPatchSetId());
         info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
         cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
       }
     }
 
+    private PatchSet.Id nextPatchSetId(PatchSet.Id psId) throws IOException {
+      PatchSet.Id next = ChangeUtil.nextPatchSetId(psId);
+      while (receivePackRefCache.exactRef(next.toRefName()) != null) {
+        next = ChangeUtil.nextPatchSetId(next);
+      }
+      return next;
+    }
+
     void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
       try (TraceTimer traceTimer = newTimer("addOps")) {
         if (magicBranch != null && magicBranch.edit) {
@@ -2988,7 +3014,8 @@
                     info,
                     groups,
                     magicBranch,
-                    receivePack.getPushCertificate())
+                    receivePack.getPushCertificate(),
+                    notes.getChange())
                 .setRequestScopePropagator(requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
@@ -3091,45 +3118,6 @@
     }
   }
 
-  private List<Ref> refs(Change.Id changeId) {
-    return refsByChange().get(changeId);
-  }
-
-  private void initChangeRefMaps() {
-    if (refsByChange != null) {
-      return;
-    }
-
-    try (TraceTimer traceTimer = newTimer("initChangeRefMaps")) {
-      int estRefsPerChange = 4;
-      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
-      refsByChange =
-          MultimapBuilder.hashKeys(allRefs().size() / estRefsPerChange)
-              .arrayListValues(estRefsPerChange)
-              .build();
-      for (Ref ref : allRefs().values()) {
-        ObjectId obj = ref.getObjectId();
-        if (obj != null) {
-          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          if (psId != null) {
-            refsById.put(obj, ref);
-            refsByChange.put(psId.changeId(), ref);
-          }
-        }
-      }
-    }
-  }
-
-  private ListMultimap<Change.Id, Ref> refsByChange() {
-    initChangeRefMaps();
-    return refsByChange;
-  }
-
-  private ListMultimap<ObjectId, Ref> changeRefsById() {
-    initChangeRefMaps();
-    return refsById;
-  }
-
   private static boolean parentsEqual(RevCommit a, RevCommit b) {
     if (a.getParentCount() != b.getParentCount()) {
       return false;
@@ -3214,7 +3202,6 @@
         if (!(parsedObject instanceof RevCommit)) {
           return;
         }
-        ListMultimap<ObjectId, Ref> existing = changeRefsById();
         walk.markStart((RevCommit) parsedObject);
         markHeadsAsUninteresting(walk, cmd.getRefName());
         int limit = receiveConfig.maxBatchCommits;
@@ -3231,7 +3218,7 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (existing.keySet().contains(c)) {
+          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
             continue;
           }
 
@@ -3261,107 +3248,133 @@
       // TODO(dborowitz): Combine this BatchUpdate with the main one in
       // handleRegularCommands
       try {
-        retryHelper.execute(
-            updateFactory -> {
-              try (BatchUpdate bu =
-                      updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
-                  ObjectInserter ins = repo.newObjectInserter();
-                  ObjectReader reader = ins.newReader();
-                  RevWalk rw = new RevWalk(reader)) {
-                bu.setRepository(repo, rw, ins);
-                // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+        retryHelper
+            .changeUpdate(
+                "autoCloseChanges",
+                updateFactory -> {
+                  try (BatchUpdate bu =
+                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
+                      ObjectInserter ins = repo.newObjectInserter();
+                      ObjectReader reader = ins.newReader();
+                      RevWalk rw = new RevWalk(reader)) {
+                    bu.setRepository(repo, rw, ins);
+                    // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
-                RevCommit newTip = rw.parseCommit(cmd.getNewId());
-                BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
+                    RevCommit newTip = rw.parseCommit(cmd.getNewId());
+                    BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
 
-                rw.reset();
-                rw.sort(RevSort.REVERSE);
-                rw.markStart(newTip);
-                if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-                  rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-                }
+                    rw.reset();
+                    rw.sort(RevSort.REVERSE);
+                    rw.markStart(newTip);
+                    if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+                      rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+                    }
 
-                ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
-                Map<Change.Key, ChangeNotes> byKey = null;
-                List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+                    Map<Change.Key, ChangeNotes> byKey = null;
+                    List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
-                int existingPatchSets = 0;
-                int newPatchSets = 0;
-                COMMIT:
-                for (RevCommit c; (c = rw.next()) != null; ) {
-                  rw.parseBody(c);
+                    int existingPatchSets = 0;
+                    int newPatchSets = 0;
+                    SubmissionId submissionId = null;
+                    COMMIT:
+                    for (RevCommit c; (c = rw.next()) != null; ) {
+                      rw.parseBody(c);
 
-                  for (Ref ref : byCommit.get(c.copy())) {
-                    PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-                    Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
-                    if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
-                      existingPatchSets++;
-                      bu.addOp(notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
+                      for (Ref ref :
+                          receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
+                        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                        Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
+                        if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
+                          if (submissionId == null) {
+                            submissionId = new SubmissionId(notes.get().getChange());
+                          }
+                          existingPatchSets++;
+                          bu.addOp(
+                              notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
+                          bu.addOp(
+                              psId.changeId(),
+                              mergedByPushOpFactory.create(
+                                  requestScopePropagator,
+                                  psId,
+                                  submissionId,
+                                  refName,
+                                  newTip.getId().getName()));
+                          continue COMMIT;
+                        }
+                      }
+
+                      for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
+                        if (byKey == null) {
+                          byKey =
+                              retryHelper
+                                  .changeIndexQuery(
+                                      "queryOpenChangesByKeyByBranch",
+                                      q -> openChangesByKeyByBranch(q, branch))
+                                  .call();
+                        }
+
+                        ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
+                        if (onto != null) {
+                          newPatchSets++;
+                          // Hold onto this until we're done with the walk, as the call to
+                          // req.validate below calls isMergedInto which resets the walk.
+                          ReplaceRequest req =
+                              new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+                          req.notes = onto;
+                          replaceAndClose.add(req);
+                          continue COMMIT;
+                        }
+                      }
+                    }
+
+                    for (ReplaceRequest req : replaceAndClose) {
+                      Change.Id id = req.notes.getChangeId();
+                      if (!req.validateNewPatchSetForAutoClose()) {
+                        logger.atFine().log("Not closing %s because validation failed", id);
+                        continue;
+                      }
+                      if (submissionId == null) {
+                        submissionId = new SubmissionId(req.notes.getChange());
+                      }
+                      req.addOps(bu, null);
+                      bu.addOp(id, setPrivateOpFactory.create(false, null));
                       bu.addOp(
-                          psId.changeId(),
-                          mergedByPushOpFactory.create(
-                              requestScopePropagator, psId, refName, newTip.getId().getName()));
-                      continue COMMIT;
-                    }
-                  }
-
-                  for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
-                    if (byKey == null) {
-                      byKey = executeIndexQuery(() -> openChangesByKeyByBranch(branch));
+                          id,
+                          mergedByPushOpFactory
+                              .create(
+                                  requestScopePropagator,
+                                  req.psId,
+                                  submissionId,
+                                  refName,
+                                  newTip.getId().getName())
+                              .setPatchSetProvider(req.replaceOp::getPatchSet));
+                      bu.addOp(id, new ChangeProgressOp(progress));
+                      ids.add(id);
                     }
 
-                    ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
-                    if (onto != null) {
-                      newPatchSets++;
-                      // Hold onto this until we're done with the walk, as the call to
-                      // req.validate below calls isMergedInto which resets the walk.
-                      ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-                      req.notes = onto;
-                      replaceAndClose.add(req);
-                      continue COMMIT;
-                    }
+                    logger.atFine().log(
+                        "Auto-closing %d changes with existing patch sets and %d with new patch"
+                            + " sets",
+                        existingPatchSets, newPatchSets);
+                    bu.execute();
+                  } catch (IOException | StorageException | PermissionBackendException e) {
+                    logger.atSevere().withCause(e).log("Failed to auto-close changes");
+                    return null;
                   }
-                }
 
-                for (ReplaceRequest req : replaceAndClose) {
-                  Change.Id id = req.notes.getChangeId();
-                  if (!req.validateNewPatchSetForAutoClose()) {
-                    logger.atFine().log("Not closing %s because validation failed", id);
-                    continue;
-                  }
-                  req.addOps(bu, null);
-                  bu.addOp(id, setPrivateOpFactory.create(false, null));
-                  bu.addOp(
-                      id,
-                      mergedByPushOpFactory
-                          .create(
-                              requestScopePropagator, req.psId, refName, newTip.getId().getName())
-                          .setPatchSetProvider(req.replaceOp::getPatchSet));
-                  bu.addOp(id, new ChangeProgressOp(progress));
-                  ids.add(id);
-                }
+                  // If we are here, we didn't throw UpdateException. Record the result.
+                  // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id
+                  // doesn't
+                  // fit into TreeSet.
+                  ids.stream()
+                      .forEach(id -> resultChangeIds.add(ResultChangeIds.Key.AUTOCLOSED, id));
 
-                logger.atFine().log(
-                    "Auto-closing %d changes with existing patch sets and %d with new patch sets",
-                    existingPatchSets, newPatchSets);
-                bu.execute();
-              } catch (IOException | StorageException | PermissionBackendException e) {
-                logger.atSevere().withCause(e).log("Failed to auto-close changes");
-                return null;
-              }
-
-              // If we are here, we didn't throw UpdateException. Record the result.
-              // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id doesn't
-              // fit into TreeSet.
-              ids.stream().forEach(id -> resultChangeIds.add(ResultChangeIds.Key.AUTOCLOSED, id));
-
-              return null;
-            },
+                  return null;
+                })
             // Use a multiple of the default timeout to account for inner retries that may otherwise
             // eat up the whole timeout so that no time is left to retry this outer action.
-            RetryHelper.options()
-                .timeout(retryHelper.getDefaultTimeout(ActionType.CHANGE_UPDATE).multipliedBy(5))
-                .build());
+            .defaultTimeoutMultiplier(5)
+            .call();
       } catch (RestApiException e) {
         logger.atSevere().withCause(e).log("Can't insert patchset");
       } catch (UpdateException e) {
@@ -3378,21 +3391,12 @@
     }
   }
 
-  private <T> T executeIndexQuery(Action<T> action) {
-    try (TraceTimer traceTimer = newTimer("executeIndexQuery")) {
-      return retryHelper.execute(
-          ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new StorageException(e);
-    }
-  }
-
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(BranchNameKey branch) {
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(
+      InternalChangeQuery internalChangeQuery, BranchNameKey branch) {
     try (TraceTimer traceTimer =
         newTimer("openChangesByKeyByBranch", Metadata.builder().branchName(branch.branch()))) {
       Map<Change.Key, ChangeNotes> r = new HashMap<>();
-      for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
+      for (ChangeData cd : internalChangeQuery.byBranchOpen(branch)) {
         try {
           r.put(cd.change().getKey(), cd.notes());
         } catch (NoSuchChangeException e) {
@@ -3403,13 +3407,6 @@
     }
   }
 
-  // allRefsWatcher hooks into the protocol negotation to get a list of all known refs.
-  // This is used as a cache of ref -> sha1 values, and to build an inverse index
-  // of (change => list of refs) and a (SHA1 => refs).
-  private Map<String, Ref> allRefs() {
-    return allRefsWatcher.getAllRefs();
-  }
-
   private TraceTimer newTimer(String name) {
     return newTimer(getClass(), name);
   }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 83bf554..6c1f097 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -18,14 +18,19 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.git.HookUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.OwnerPredicate;
+import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -65,11 +70,13 @@
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final Project.NameKey projectName;
+  private final Account.Id user;
 
   public ReceiveCommitsAdvertiseRefsHook(
-      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName, Account.Id user) {
     this.queryProvider = queryProvider;
     this.projectName = projectName;
+    this.user = user;
   }
 
   @Override
@@ -90,7 +97,9 @@
 
   private Set<ObjectId> advertiseOpenChanges(Repository repo)
       throws ServiceMayNotContinueException {
-    // Advertise some recent open changes, in case a commit is based on one.
+    // Advertise the user's most recent open changes. It's likely that the user has one of these in
+    // their local repo and they can serve as starting points to figure out the common ancestor of
+    // what the client and server have in common.
     int limit = 32;
     try {
       Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
@@ -105,7 +114,11 @@
                   ChangeField.PATCH_SET)
               .enforceVisibility(true)
               .setLimit(limit)
-              .byProjectOpen(projectName)) {
+              .query(
+                  Predicate.and(
+                      new ProjectPredicate(projectName.get()),
+                      ChangeStatusPredicate.open(),
+                      new OwnerPredicate(user)))) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
           // Ensure we actually observed a patch set ref pointing to this
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
index 76f6b04..fae1401 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHookChain.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.receive;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Provider;
@@ -35,8 +36,9 @@
   public static AdvertiseRefsHook create(
       AllRefsWatcher allRefsWatcher,
       Provider<InternalChangeQuery> queryProvider,
-      Project.NameKey projectName) {
-    return create(allRefsWatcher, queryProvider, projectName, false);
+      Project.NameKey projectName,
+      Account.Id user) {
+    return create(allRefsWatcher, queryProvider, projectName, user, false);
   }
 
   /**
@@ -47,18 +49,19 @@
    */
   @VisibleForTesting
   public static AdvertiseRefsHook createForTest(
-      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
-    return create(new AllRefsWatcher(), queryProvider, projectName, true);
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName, Account.Id user) {
+    return create(new AllRefsWatcher(), queryProvider, projectName, user, true);
   }
 
   private static AdvertiseRefsHook create(
       AllRefsWatcher allRefsWatcher,
       Provider<InternalChangeQuery> queryProvider,
       Project.NameKey projectName,
+      Account.Id user,
       boolean skipHackPushNegotiateHook) {
     List<AdvertiseRefsHook> advHooks = new ArrayList<>();
     advHooks.add(allRefsWatcher);
-    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
+    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName, user));
     if (!skipHackPushNegotiateHook) {
       advHooks.add(new HackPushNegotiateHook());
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
new file mode 100644
index 0000000..376ab2d
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+
+/**
+ * Simple cache for accessing refs by name, prefix or {@link ObjectId}. Intended to be used when
+ * processing a {@code git push}.
+ *
+ * <p>This class is not thread safe.
+ */
+public interface ReceivePackRefCache {
+
+  /**
+   * Returns an instance that delegates all calls to the provided {@link RefDatabase}. To be used in
+   * tests or when the ref database is fast with forward (name to {@link ObjectId}) and inverse
+   * ({@code ObjectId} to name) lookups.
+   */
+  static ReceivePackRefCache noCache(RefDatabase delegate) {
+    return new NoCache(delegate);
+  }
+
+  /**
+   * Returns an instance that answers calls based on refs previously advertised and captured in
+   * {@link AllRefsWatcher}. Speeds up inverse lookups by building a {@code Map<ObjectId,
+   * List<Ref>>} and a {@code Map<Change.Id, List<Ref>>}.
+   *
+   * <p>This implementation speeds up lookups when the ref database does not support inverse ({@code
+   * ObjectId} to name) lookups.
+   */
+  static ReceivePackRefCache withAdvertisedRefs(Supplier<Map<String, Ref>> allRefsSupplier) {
+    return new WithAdvertisedRefs(allRefsSupplier);
+  }
+
+  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
+  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+
+  /** Returns all refs whose name starts with {@code prefix}. */
+  ImmutableList<Ref> byPrefix(String prefix) throws IOException;
+
+  /** Returns a ref whose name matches {@code ref} or {@code null} if such a ref does not exist. */
+  @Nullable
+  Ref exactRef(String ref) throws IOException;
+
+  class NoCache implements ReceivePackRefCache {
+    private final RefDatabase delegate;
+
+    private NoCache(RefDatabase delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
+        throws IOException {
+      return delegate.getTipsWithSha1(id).stream()
+          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .collect(toImmutableList());
+    }
+
+    @Override
+    public ImmutableList<Ref> byPrefix(String prefix) throws IOException {
+      return delegate.getRefsByPrefix(prefix).stream().collect(toImmutableList());
+    }
+
+    @Override
+    @Nullable
+    public Ref exactRef(String name) throws IOException {
+      return delegate.exactRef(name);
+    }
+  }
+
+  class WithAdvertisedRefs implements ReceivePackRefCache {
+    /** We estimate that a change has an average of 4 patch sets plus the meta ref. */
+    private static final int ESTIMATED_NUMBER_OF_REFS_PER_CHANGE = 5;
+
+    private final Supplier<Map<String, Ref>> allRefsSupplier;
+
+    // Collections lazily populated during processing.
+    private Map<String, Ref> allRefs;
+    /** Contains only patch set refs. */
+    private ListMultimap<Change.Id, Ref> refsByChange;
+    /** Contains all refs. */
+    private ListMultimap<ObjectId, Ref> refsByObjectId;
+
+    private WithAdvertisedRefs(Supplier<Map<String, Ref>> allRefsSupplier) {
+      this.allRefsSupplier = allRefsSupplier;
+    }
+
+    @Override
+    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+      lazilyInitRefMaps();
+      return refsByObjectId.get(id).stream()
+          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .collect(toImmutableList());
+    }
+
+    @Override
+    public ImmutableList<Ref> byPrefix(String prefix) {
+      lazilyInitRefMaps();
+      if (RefNames.isRefsChanges(prefix)) {
+        Change.Id cId = Change.Id.fromRefPart(prefix);
+        if (cId != null) {
+          return refsByChange.get(cId).stream()
+              .filter(r -> r.getName().startsWith(prefix))
+              .collect(toImmutableList());
+        }
+      }
+      return allRefs().values().stream()
+          .filter(r -> r.getName().startsWith(prefix))
+          .collect(toImmutableList());
+    }
+
+    @Override
+    @Nullable
+    public Ref exactRef(String name) {
+      return allRefs().get(name);
+    }
+
+    private Map<String, Ref> allRefs() {
+      if (allRefs == null) {
+        allRefs = allRefsSupplier.get();
+      }
+      return allRefs;
+    }
+
+    private void lazilyInitRefMaps() {
+      if (refsByChange != null) {
+        return;
+      }
+
+      refsByObjectId = MultimapBuilder.hashKeys().arrayListValues().build();
+      refsByChange =
+          MultimapBuilder.hashKeys(allRefs().size() / ESTIMATED_NUMBER_OF_REFS_PER_CHANGE)
+              .arrayListValues(ESTIMATED_NUMBER_OF_REFS_PER_CHANGE)
+              .build();
+      for (Ref ref : allRefs().values()) {
+        ObjectId objectId = ref.getObjectId();
+        if (objectId != null) {
+          refsByObjectId.put(objectId, ref);
+          Change.Id changeId = Change.Id.fromRef(ref.getName());
+          if (changeId != null) {
+            refsByChange.put(changeId, ref);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index e95cf3b..8a76a4d 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -32,10 +32,10 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -44,13 +44,10 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
@@ -109,7 +106,8 @@
         PatchSetInfo info,
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
-        @Nullable PushCertificate pushCertificate);
+        @Nullable PushCertificate pushCertificate,
+        Change change);
   }
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
@@ -119,9 +117,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final EmailReviewComments.Factory emailCommentsFactory;
   private final ExecutorService sendEmailExecutor;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
@@ -142,6 +137,7 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
+  private final Change change;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -151,7 +147,6 @@
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private ChangeMessage msg;
-  private List<Comment> comments = ImmutableList.of();
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
@@ -165,9 +160,6 @@
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
       ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      EmailReviewComments.Factory emailCommentsFactory,
       RevisionCreated revisionCreated,
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
@@ -176,6 +168,7 @@
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
+      Change change,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -193,9 +186,6 @@
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
     this.cmUtil = cmUtil;
-    this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.emailCommentsFactory = emailCommentsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
@@ -217,6 +207,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.change = change;
   }
 
   @Override
@@ -236,7 +227,11 @@
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
-                requestScopePropagator, patchSetId, mergedInto, mergeResultRevId);
+                requestScopePropagator,
+                patchSetId,
+                new SubmissionId(change),
+                mergedInto,
+                mergeResultRevId);
       }
     }
 
@@ -293,13 +288,6 @@
         change.setWorkInProgress(true);
         update.setWorkInProgress(true);
       }
-      if (shouldPublishComments()) {
-        boolean workInProgress = change.isWorkInProgress();
-        if (magicBranch.workInProgress) {
-          workInProgress = true;
-        }
-        comments = publishComments(ctx, workInProgress);
-      }
     }
 
     newPatchSet =
@@ -359,10 +347,16 @@
         Streams.concat(
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
-                    change, psInfo.getAuthor().getAccount(), NotifyHandling.NONE)),
+                    change,
+                    psInfo.getCommitId(),
+                    psInfo.getAuthor().getAccount(),
+                    NotifyHandling.NONE)),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
-                    change, psInfo.getCommitter().getAccount(), NotifyHandling.NONE)));
+                    change,
+                    psInfo.getCommitId(),
+                    psInfo.getCommitter().getAccount(),
+                    NotifyHandling.NONE)));
     if (magicBranch != null) {
       inputs =
           Streams.concat(
@@ -401,11 +395,6 @@
     } else {
       message.append('.');
     }
-    if (comments.size() == 1) {
-      message.append("\n\n(1 comment)");
-    } else if (comments.size() > 1) {
-      message.append(String.format("\n\n(%d comments)", comments.size()));
-    }
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n\n").append(reviewMessage);
     }
@@ -484,14 +473,6 @@
     change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
   }
 
-  private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress) {
-    List<Comment> comments =
-        commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
-    publishCommentUtil.publish(
-        ctx, patchSetId, comments, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-    return comments;
-  }
-
   @Override
   public void postUpdate(Context ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
@@ -505,25 +486,10 @@
         e.run();
       }
     }
-
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-    if (shouldPublishComments()) {
-      emailCommentsFactory
-          .create(
-              notify,
-              notes,
-              newPatchSet,
-              ctx.getUser().asIdentifiedUser(),
-              msg,
-              comments,
-              msg.getMessage(),
-              ImmutableList.of()) // TODO(dborowitz): Include labels.
-          .sendAsync();
-    }
-
     revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
-      fireCommentAddedEvent(ctx);
+      fireApprovalsEvent(ctx);
     } catch (Exception e) {
       logger.atWarning().withCause(e).log("comment-added event invocation failed");
     }
@@ -573,11 +539,10 @@
     }
   }
 
-  private void fireCommentAddedEvent(Context ctx) throws IOException {
+  private void fireApprovalsEvent(Context ctx) throws IOException {
     if (approvals.isEmpty()) {
       return;
     }
-
     /* For labels that are not set in this operation, show the "current" value
      * of 0, and no oldValue as the value was not modified by this operation.
      * For labels that are set in this operation, the value was modified, so
@@ -597,7 +562,6 @@
         oldApprovals.put(entry.getKey(), (short) 0);
       }
     }
-
     commentAdded.fire(
         notes.getChange(),
         newPatchSet,
@@ -648,8 +612,4 @@
       return null;
     }
   }
-
-  private boolean shouldPublishComments() {
-    return magicBranch != null && magicBranch.shouldPublishComments();
-  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index c6af49c..4755f5f 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -36,6 +36,11 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Validator that is used to ensure that new commits on any ref in {@code refs/users} are conforming
+ * to the NoteDb format for accounts. Used when a user pushes to one of the refs in {@code
+ * refs/users} manually.
+ */
 public class AccountValidator {
 
   private final Provider<IdentifiedUser> self;
@@ -52,6 +57,10 @@
     this.emailValidator = emailValidator;
   }
 
+  /**
+   * Returns a list of validation messages. An empty list means that there were no issues found. If
+   * the list is non-empty, the commit will be rejected.
+   */
   public List<String> validate(
       Account.Id accountId,
       Repository allUsersRepo,
diff --git a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
new file mode 100644
index 0000000..2c939c8
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.validators;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+
+/** Limits number of comments to prevent space/time complexity issues. */
+public class CommentCountValidator implements CommentValidator {
+  private final int maxComments;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  CommentCountValidator(@GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
+    this.notesFactory = notesFactory;
+    maxComments = serverConfig.getInt("change", "maxComments", 5_000);
+  }
+
+  @Override
+  public ImmutableList<CommentValidationFailure> validateComments(
+      CommentValidationContext ctx, ImmutableList<CommentForValidation> comments) {
+    ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
+    ChangeNotes notes =
+        notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
+    int numExistingComments = notes.getComments().size() + notes.getRobotComments().size();
+    if (!comments.isEmpty() && numExistingComments + comments.size() > maxComments) {
+      // This warning really applies to the set of all comments, but we need to pick one to attach
+      // the message to.
+      CommentForValidation commentForFailureMessage = Iterables.getLast(comments);
+
+      failures.add(
+          commentForFailureMessage.failValidation(
+              String.format(
+                  "Exceeding maximum number of comments: %d (existing) + %d (new) > %d",
+                  numExistingComments, comments.size(), maxComments)));
+    }
+    return failures.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java
new file mode 100644
index 0000000..58b0cb1
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.validators;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+
+/** Limits the size of comments to prevent space/time complexity issues. */
+public class CommentSizeValidator implements CommentValidator {
+  private final int commentSizeLimit;
+  private final int robotCommentSizeLimit;
+
+  @Inject
+  CommentSizeValidator(@GerritServerConfig Config serverConfig) {
+    commentSizeLimit = serverConfig.getInt("change", "commentSizeLimit", 16 << 10);
+    robotCommentSizeLimit = serverConfig.getInt("change", "robotCommentSizeLimit", 1 << 20);
+  }
+
+  @Override
+  public ImmutableList<CommentValidationFailure> validateComments(
+      CommentValidationContext ctx, ImmutableList<CommentForValidation> comments) {
+    return comments.stream()
+        .filter(this::exceedsSizeLimit)
+        .map(c -> c.failValidation(buildErrorMessage(c)))
+        .collect(ImmutableList.toImmutableList());
+  }
+
+  private boolean exceedsSizeLimit(CommentForValidation comment) {
+    switch (comment.getSource()) {
+      case HUMAN:
+        return comment.getApproximateSize() > commentSizeLimit;
+      case ROBOT:
+        return robotCommentSizeLimit > 0 && comment.getApproximateSize() > robotCommentSizeLimit;
+    }
+    throw new RuntimeException(
+        "Unknown comment source (should not have compiled): " + comment.getSource());
+  }
+
+  private String buildErrorMessage(CommentForValidation comment) {
+    switch (comment.getSource()) {
+      case HUMAN:
+        return String.format(
+            "Comment size exceeds limit (%d > %d)", comment.getApproximateSize(), commentSizeLimit);
+
+      case ROBOT:
+        return String.format(
+            "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
+            comment.getApproximateSize(), robotCommentSizeLimit);
+    }
+    throw new RuntimeException(
+        "Unknown comment source (should not have compiled): " + comment.getSource());
+  }
+}
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index aeead63..220fc12 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -18,6 +18,11 @@
 import com.google.gerrit.server.validators.ValidationException;
 import java.util.List;
 
+/**
+ * Exception thrown when a Git commit fails validations. Gerrit supports a wide range of validations
+ * (for example it validates any commits pushed to NoteDb refs for format compliance or allows to
+ * enforce commit message lengths to not exceed a certain length).
+ */
 public class CommitValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
   private final ImmutableList<CommitValidationMessage> messages;
@@ -47,11 +52,12 @@
     this.messages = ImmutableList.of();
   }
 
+  /** Returns all validation messages individually. */
   public ImmutableList<CommitValidationMessage> getMessages() {
     return messages;
   }
 
-  /** @return the reason string along with all validation messages. */
+  /** Returns all validation as a single, formatted string. */
   public String getFullMessage() {
     StringBuilder sb = new StringBuilder(getMessage());
     if (!messages.isEmpty()) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index b3c3230..4d2dbef 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -44,6 +44,11 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.validators.ValidationMessage.Type;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -76,7 +81,8 @@
 import org.eclipse.jgit.util.SystemReader;
 
 /**
- * Represents a list of CommitValidationListeners to run for a push to one branch of one project.
+ * Represents a list of {@link CommitValidationListener}s to run for a push to one branch of one
+ * project.
  */
 public class CommitValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -94,15 +100,16 @@
     private final AllProjectsName allProjects;
     private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
     private final AccountValidator accountValidator;
-    private final String installCommitMsgHookCommand;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final PatchListCache patchListCache;
+    private final Config config;
 
     @Inject
     Factory(
         @GerritPersonIdent PersonIdent gerritIdent,
         DynamicItem<UrlFormatter> urlFormatter,
-        @GerritServerConfig Config cfg,
+        @GerritServerConfig Config config,
         PluginSetContext<CommitValidationListener> pluginValidators,
         GitRepositoryManager repoManager,
         AllUsersName allUsers,
@@ -110,19 +117,20 @@
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
         ProjectCache projectCache,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        PatchListCache patchListCache) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
+      this.config = config;
       this.pluginValidators = pluginValidators;
       this.repoManager = repoManager;
       this.allUsers = allUsers;
       this.allProjects = allProjects;
       this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
       this.accountValidator = accountValidator;
-      this.installCommitMsgHookCommand =
-          cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
+      this.patchListCache = patchListCache;
     }
 
     public CommitValidators forReceiveCommits(
@@ -143,15 +151,11 @@
               new ProjectStateValidationListener(projectState),
               new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
               new AuthorUploaderValidator(user, perm, urlFormatter.get()),
+              new FileCountValidator(patchListCache, config),
               new CommitterUploaderValidator(user, perm, urlFormatter.get()),
               new SignedOffByValidator(user, perm, projectState),
               new ChangeIdValidator(
-                  projectState,
-                  user,
-                  urlFormatter.get(),
-                  installCommitMsgHookCommand,
-                  sshInfo,
-                  change),
+                  projectState, user, urlFormatter.get(), config, sshInfo, change),
               new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new BannedCommitsValidator(rejectCommits),
               new PluginCommitValidationListener(pluginValidators, skipValidation),
@@ -176,14 +180,10 @@
               new ProjectStateValidationListener(projectState),
               new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
               new AuthorUploaderValidator(user, perm, urlFormatter.get()),
+              new FileCountValidator(patchListCache, config),
               new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.project())),
               new ChangeIdValidator(
-                  projectState,
-                  user,
-                  urlFormatter.get(),
-                  installCommitMsgHookCommand,
-                  sshInfo,
-                  change),
+                  projectState, user, urlFormatter.get(), config, sshInfo, change),
               new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new PluginCommitValidationListener(pluginValidators),
               new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
@@ -268,14 +268,14 @@
         ProjectState projectState,
         IdentifiedUser user,
         UrlFormatter urlFormatter,
-        String installCommitMsgHookCommand,
+        Config config,
         SshInfo sshInfo,
         Change change) {
       this.projectState = projectState;
-      this.urlFormatter = urlFormatter;
-      this.installCommitMsgHookCommand = installCommitMsgHookCommand;
-      this.sshInfo = sshInfo;
       this.user = user;
+      this.urlFormatter = urlFormatter;
+      installCommitMsgHookCommand = config.getString("gerrit", null, "installCommitMsgHookCommand");
+      this.sshInfo = sshInfo;
       this.change = change;
     }
 
@@ -387,6 +387,44 @@
     }
   }
 
+  /** Limits the number of files per change. */
+  private static class FileCountValidator implements CommitValidationListener {
+
+    private final PatchListCache patchListCache;
+    private final int maxFileCount;
+
+    FileCountValidator(PatchListCache patchListCache, Config config) {
+      this.patchListCache = patchListCache;
+      maxFileCount = config.getInt("change", null, "maxFiles", 100_000);
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      PatchListKey patchListKey =
+          PatchListKey.againstBase(
+              receiveEvent.commit.getId(), receiveEvent.commit.getParentCount());
+      DiffSummaryKey diffSummaryKey = DiffSummaryKey.fromPatchListKey(patchListKey);
+      try {
+        DiffSummary diffSummary =
+            patchListCache.getDiffSummary(diffSummaryKey, receiveEvent.project.getNameKey());
+        if (diffSummary.getPaths().size() > maxFileCount) {
+          throw new CommitValidationException(
+              String.format(
+                  "Exceeding maximum number of files per change (%d > %d)",
+                  diffSummary.getPaths().size(), maxFileCount));
+        }
+      } catch (PatchListNotAvailableException e) {
+        // This happens e.g. for cherrypicks.
+        if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
+          logger.atWarning().withCause(e).log(
+              "Failed to validate file count for commit: %s", receiveEvent.commit.toString());
+        }
+      }
+      return Collections.emptyList();
+    }
+  }
+
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
     private final ProjectConfig.Factory projectConfigFactory;
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index e17e129..9c557a7 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -52,6 +52,15 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Collection of validators that run inside Gerrit before a change is submitted. The main purpose is
+ * to ensure that NoteDb data is mutated in a controlled way.
+ *
+ * <p>The difference between this and {@link OnSubmitValidators} is that this validates the original
+ * commit. Depending on the {@link com.google.gerrit.server.submit.SubmitStrategy} that the project
+ * chooses, the resulting commit in the repo might differ from this original commit. In case you
+ * want to validate the resulting commit, use {@link OnSubmitValidators}
+ */
 public class MergeValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -76,6 +85,10 @@
     this.groupValidatorFactory = groupValidatorFactory;
   }
 
+  /**
+   * Runs all validators and throws a {@link MergeValidationException} for the first validator that
+   * failed. Only the first violation is propagated and processing is stopped thereafter.
+   */
   public void validatePreMerge(
       Repository repo,
       CodeReviewCommit commit,
@@ -96,6 +109,7 @@
     }
   }
 
+  /** Validator for any commits to {@code refs/meta/config}. */
   public static class ProjectConfigValidator implements MergeValidationListener {
     private static final String INVALID_CONFIG =
         "Change contains an invalid project configuration.";
@@ -237,7 +251,7 @@
     }
   }
 
-  /** Execute merge validation plug-ins */
+  /** Validator that calls to plugins that provide additional validators. */
   public static class PluginMergeValidationListener implements MergeValidationListener {
     private final PluginSetContext<MergeValidationListener> mergeValidationListeners;
 
@@ -318,6 +332,7 @@
     }
   }
 
+  /** Validator to ensure that group refs are not mutated. */
   public static class GroupMergeValidator implements MergeValidationListener {
     public interface Factory {
       GroupMergeValidator create();
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
index d27cc38..9fbca00 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidationException.java
@@ -19,6 +19,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
 
+/**
+ * Exception to be thrown when the validation of a ref operation fails and should be aborted.
+ * Examples of a ref operations include creating or updating refs.
+ */
 public class RefOperationValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
   private final ImmutableList<ValidationMessage> messages;
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index e9734a3..f3b6983 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -36,6 +36,12 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
+/**
+ * Collection of validation listeners that are called before a ref update is performed with the
+ * command to be run. This is called from the git push path as well as Gerrit's handlers for
+ * creating or deleting refs. Calls out to {@link RefOperationValidationListener} provided by
+ * plugins.
+ */
 public class RefOperationValidators {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -70,6 +76,11 @@
     event.user = user;
   }
 
+  /**
+   * Returns informational validation messages and throws a {@link RefOperationValidationException}
+   * when the first validator fails. Will not process any more validators after the first failure
+   * was encountered.
+   */
   public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
diff --git a/java/com/google/gerrit/server/git/validators/UploadValidationException.java b/java/com/google/gerrit/server/git/validators/UploadValidationException.java
index be264b6..da2fb98 100644
--- a/java/com/google/gerrit/server/git/validators/UploadValidationException.java
+++ b/java/com/google/gerrit/server/git/validators/UploadValidationException.java
@@ -16,6 +16,7 @@
 
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 
+/** Exception to be thrown when an {@link UploadValidationListener} fails. */
 public class UploadValidationException extends ServiceMayNotContinueException {
 
   private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/server/git/validators/UploadValidators.java b/java/com/google/gerrit/server/git/validators/UploadValidators.java
index 6847a28..60360a2 100644
--- a/java/com/google/gerrit/server/git/validators/UploadValidators.java
+++ b/java/com/google/gerrit/server/git/validators/UploadValidators.java
@@ -26,6 +26,7 @@
 import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 
+/** Collection of validators to run before Gerrit sends pack data to a client. */
 public class UploadValidators implements PreUploadHook {
 
   private final PluginSetContext<UploadValidationListener> uploadValidationListeners;
diff --git a/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
index db59492..faf29fe 100644
--- a/java/com/google/gerrit/server/git/validators/ValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.git.validators;
 
+/**
+ * Message used as result of a validation that run during a git operation (for example {@code git
+ * push}. Intended to be shown to users.
+ */
 public class ValidationMessage {
   public enum Type {
     ERROR("ERROR: "),
@@ -35,24 +39,34 @@
   private final String message;
   private final Type type;
 
+  /** @see ValidationMessage */
   public ValidationMessage(String message, Type type) {
     this.message = message;
     this.type = type;
   }
 
+  // TODO: Remove and move callers to ValidationMessage(String message, Type type)
   public ValidationMessage(String message, boolean isError) {
     this.message = message;
     this.type = (isError ? Type.ERROR : Type.OTHER);
   }
 
+  /** Returns the message to be shown to the user. */
   public String getMessage() {
     return message;
   }
 
+  /**
+   * Returns the {@link Type}. Used to as prefix for the message in the git CLI and to color
+   * messages.
+   */
   public Type getType() {
     return type;
   }
 
+  /**
+   * Returns {@true} if this message is an error. Used to decide if the operation should be aborted.
+   */
   public boolean isError() {
     return type == Type.ERROR;
   }
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index a54f465..1aa265b 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
@@ -28,6 +29,8 @@
 
 @Singleton
 public class GroupResolver {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final GroupBackend groupBackend;
   private final GroupCache groupCache;
   private final GroupControl.Factory groupControlFactory;
@@ -81,36 +84,48 @@
    * @return the group, null if no group is found for the given group ID
    */
   public GroupDescription.Basic parseId(String id) {
+    logger.atFine().log("Parsing group %s", id);
+
     AccountGroup.UUID uuid = AccountGroup.uuid(id);
     if (groupBackend.handles(uuid)) {
+      logger.atFine().log("Group UUID %s is handled by a group backend", uuid.get());
       GroupDescription.Basic d = groupBackend.get(uuid);
       if (d != null) {
+        logger.atFine().log("Found group %s", d.getName());
         return d;
       }
     }
 
     // Might be a numeric AccountGroup.Id. -> Internal group.
     if (id.matches("^[1-9][0-9]*$")) {
+      logger.atFine().log("Group ID %s is a numeric ID", id);
       try {
         AccountGroup.Id groupId = AccountGroup.Id.parse(id);
         Optional<InternalGroup> group = groupCache.get(groupId);
         if (group.isPresent()) {
+          logger.atFine().log(
+              "Found internal group %s (UUID = %s)",
+              group.get().getName(), group.get().getGroupUUID().get());
           return new InternalGroupDescription(group.get());
         }
       } catch (IllegalArgumentException e) {
         // Ignored
+        logger.atFine().withCause(e).log("Parsing numeric group ID %s failed", id);
       }
     }
 
     // Might be a group name, be nice and accept unique names.
+    logger.atFine().log("Try finding a group with name %s", id);
     GroupReference ref = GroupBackends.findExactSuggestion(groupBackend, id);
     if (ref != null) {
       GroupDescription.Basic d = groupBackend.get(ref.getUUID());
       if (d != null) {
+        logger.atFine().log("Found group %s", d.getName());
         return d;
       }
     }
 
+    logger.atFine().log("Group %s not found", id);
     return null;
   }
 }
diff --git a/java/com/google/gerrit/server/group/InternalGroup.java b/java/com/google/gerrit/server/group/InternalGroup.java
index 639cd7a..f33adaf 100644
--- a/java/com/google/gerrit/server/group/InternalGroup.java
+++ b/java/com/google/gerrit/server/group/InternalGroup.java
@@ -27,32 +27,6 @@
 public abstract class InternalGroup implements Serializable {
   private static final long serialVersionUID = 1L;
 
-  public static InternalGroup create(
-      AccountGroup accountGroup,
-      ImmutableSet<Account.Id> members,
-      ImmutableSet<AccountGroup.UUID> subgroups) {
-    return create(accountGroup, members, subgroups, null);
-  }
-
-  public static InternalGroup create(
-      AccountGroup accountGroup,
-      ImmutableSet<Account.Id> members,
-      ImmutableSet<AccountGroup.UUID> subgroups,
-      ObjectId refState) {
-    return builder()
-        .setId(accountGroup.getId())
-        .setNameKey(accountGroup.getNameKey())
-        .setDescription(accountGroup.getDescription())
-        .setOwnerGroupUUID(accountGroup.getOwnerGroupUUID())
-        .setVisibleToAll(accountGroup.isVisibleToAll())
-        .setGroupUUID(accountGroup.getGroupUUID())
-        .setCreatedOn(accountGroup.getCreatedOn())
-        .setMembers(members)
-        .setSubgroups(subgroups)
-        .setRefState(refState)
-        .build();
-  }
-
   public abstract AccountGroup.Id getId();
 
   public String getName() {
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 2925cb3..7a1b351f 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -124,8 +124,7 @@
           getGroupFromNoteDb(allUsersName, allUsersRepo, internalGroup.getUUID());
       group.map(InternalGroup::getSubgroups).ifPresent(allSubgroups::addAll);
     }
-    return allSubgroups.build().stream()
-        .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
+    return allSubgroups.build().stream().filter(groupUuid -> !groupUuid.isInternalGroup());
   }
 
   /**
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 7f1ba6a..4ec5c36 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -309,10 +308,9 @@
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try {
-      return retryHelper.execute(
-          RetryHelper.ActionType.GROUP_UPDATE,
-          () -> createGroupInNoteDb(groupCreation, groupUpdate),
-          LockFailureException.class::isInstance);
+      return retryHelper
+          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupUpdate))
+          .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
@@ -349,10 +347,9 @@
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try {
-      return retryHelper.execute(
-          RetryHelper.ActionType.GROUP_UPDATE,
-          () -> updateGroupInNoteDb(groupUuid, groupUpdate),
-          LockFailureException.class::isInstance);
+      return retryHelper
+          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupUpdate))
+          .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 995b4b6..352971f 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -28,6 +28,10 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * Base class to establish implementation-independent index bindings. To be subclassed by concrete
+ * index implementations, such as {@link com.google.gerrit.lucene.LuceneIndexModule}.
+ */
 public abstract class AbstractIndexModule extends AbstractModule {
 
   private final int threads;
diff --git a/java/com/google/gerrit/server/index/DummyIndexModule.java b/java/com/google/gerrit/server/index/DummyIndexModule.java
deleted file mode 100644
index 8b450bc..0000000
--- a/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.DummyChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.AbstractModule;
-
-public class DummyIndexModule extends AbstractModule {
-  private static class DummyChangeIndexFactory implements ChangeIndex.Factory {
-    @Override
-    public ChangeIndex create(Schema<ChangeData> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  private static class DummyAccountIndexFactory implements AccountIndex.Factory {
-    @Override
-    public AccountIndex create(Schema<AccountState> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  private static class DummyGroupIndexFactory implements GroupIndex.Factory {
-    @Override
-    public GroupIndex create(Schema<InternalGroup> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  private static class DummyProjectIndexFactory implements ProjectIndex.Factory {
-    @Override
-    public ProjectIndex create(Schema<ProjectData> schema) {
-      throw new UnsupportedOperationException();
-    }
-  }
-
-  @Override
-  protected void configure() {
-    install(new IndexModule(1, true));
-    bind(IndexConfig.class).toInstance(IndexConfig.createDefault());
-    bind(Index.class).toInstance(new DummyChangeIndex());
-    bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
-    bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
-    bind(GroupIndex.Factory.class).toInstance(new DummyGroupIndexFactory());
-    bind(ProjectIndex.Factory.class).toInstance(new DummyProjectIndexFactory());
-  }
-}
diff --git a/java/com/google/gerrit/server/index/GerritIndexStatus.java b/java/com/google/gerrit/server/index/GerritIndexStatus.java
index d835227..6d59100 100644
--- a/java/com/google/gerrit/server/index/GerritIndexStatus.java
+++ b/java/com/google/gerrit/server/index/GerritIndexStatus.java
@@ -22,6 +22,10 @@
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 
+/**
+ * Status to decide if a specific index version (e.g. change v55) is initialized and ready for use.
+ * An index version is ready for use after documents for all entities were created.
+ */
 public class GerritIndexStatus {
   private static final String SECTION = "index";
   private static final String KEY_READY = "ready";
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index a45777e..9e3d91c 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
@@ -36,10 +35,10 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/** Set of index-related utility methods. */
 public final class IndexUtils {
-  public static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
-      ImmutableMap.of("_", " ", ".", " ");
 
+  /** Mark an index version as ready to serve queries. */
   public static void setReady(SitePaths sitePaths, String name, int version, boolean ready) {
     try {
       GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
@@ -50,25 +49,31 @@
     }
   }
 
-  public static boolean getReady(SitePaths sitePaths, String name, int version) {
-    try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
-      return cfg.getReady(name, version);
-    } catch (ConfigInvalidException | IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
+  /**
+   * Returns a sanitized set of fields for account index queries by removing fields that the current
+   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
+   * key situation is temporary and should be removed after the migration is done.
+   */
   public static Set<String> accountFields(QueryOptions opts, boolean useLegacyNumericFields) {
     return accountFields(opts.fields(), useLegacyNumericFields);
   }
 
+  /**
+   * Returns a sanitized set of fields for account index queries by removing fields that the current
+   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
+   * key situation is temporary and should be removed after the migration is done.
+   */
   public static Set<String> accountFields(Set<String> fields, boolean useLegacyNumericFields) {
     String idFieldName =
         useLegacyNumericFields ? AccountField.ID.getName() : AccountField.ID_STR.getName();
     return fields.contains(idFieldName) ? fields : Sets.union(fields, ImmutableSet.of(idFieldName));
   }
 
+  /**
+   * Returns a sanitized set of fields for change index queries by removing fields that the current
+   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
+   * key situation is temporary and should be removed after the migration is done.
+   */
   public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
     FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
     // Ensure we request enough fields to construct a ChangeData. We need both
@@ -85,6 +90,11 @@
     return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
   }
 
+  /**
+   * Returns a sanitized set of fields for group index queries by removing fields that the index
+   * doesn't support and accounting for numeric vs. string primary keys. The primary key situation
+   * is temporary and should be removed after the migration is done.
+   */
   public static Set<String> groupFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
     return fs.contains(GroupField.UUID.getName())
@@ -92,6 +102,7 @@
         : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
   }
 
+  /** Returns a index-friendly representation of a {@link CurrentUser} to be used in queries. */
   public static String describe(CurrentUser user) {
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
@@ -102,6 +113,10 @@
     return user.toString();
   }
 
+  /**
+   * Returns a sanitized set of fields for project index queries by removing fields that the index
+   * doesn't support.
+   */
   public static Set<String> projectFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
     return fs.contains(ProjectField.NAME.getName())
diff --git a/java/com/google/gerrit/server/index/OnlineReindexMode.java b/java/com/google/gerrit/server/index/OnlineReindexMode.java
index 123229a..d935186 100644
--- a/java/com/google/gerrit/server/index/OnlineReindexMode.java
+++ b/java/com/google/gerrit/server/index/OnlineReindexMode.java
@@ -16,6 +16,7 @@
 
 import java.util.Optional;
 
+/** Per-thread singleton to signal if online reindexing is in progress. */
 public class OnlineReindexMode {
   private static ThreadLocal<Boolean> isOnlineReindex = new ThreadLocal<>();
 
diff --git a/java/com/google/gerrit/server/index/OnlineReindexer.java b/java/com/google/gerrit/server/index/OnlineReindexer.java
index 37677bdd..eef394d 100644
--- a/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -26,6 +26,11 @@
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+/**
+ * Background thread for running an index schema upgrade by reindexing all documents in an index
+ * using the new version. Intended to be run while Gerrit is serving traffic to prepare for a
+ * near-zero downtime upgrade.
+ */
 public class OnlineReindexer<K, V, I extends Index<K, V>> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -51,37 +56,38 @@
     this.listeners = listeners;
   }
 
+  /** Starts the background process. */
   public void start() {
     if (running.compareAndSet(false, true)) {
       Thread t =
-          new Thread() {
-            @Override
-            public void run() {
-              boolean ok = false;
-              try {
-                reindex();
-                ok = true;
-              } catch (RuntimeException e) {
-                logger.atSevere().withCause(e).log(
-                    "Online reindex of %s schema version %s failed", name, version(index));
-              } finally {
-                running.set(false);
-                if (!ok) {
-                  listeners.runEach(listener -> listener.onFailure(name, oldVersion, newVersion));
+          new Thread(
+              () -> {
+                boolean ok = false;
+                try {
+                  reindex();
+                  ok = true;
+                } catch (RuntimeException e) {
+                  logger.atSevere().withCause(e).log(
+                      "Online reindex of %s schema version %s failed", name, version(index));
+                } finally {
+                  running.set(false);
+                  if (!ok) {
+                    listeners.runEach(listener -> listener.onFailure(name, oldVersion, newVersion));
+                  }
                 }
-              }
-            }
-          };
+              });
       t.setName(
           String.format("Reindex %s v%d-v%d", name, version(indexes.getSearchIndex()), newVersion));
       t.start();
     }
   }
 
+  /** Returns {@code true} if the background indexer is currently running. */
   public boolean isRunning() {
     return running.get();
   }
 
+  /** Returns the index version that this indexer is creating documents for. */
   public int getVersion() {
     return newVersion;
   }
@@ -116,6 +122,10 @@
     listeners.runEach(listener -> listener.onSuccess(name, oldVersion, newVersion));
   }
 
+  /**
+   * Switches the search index from the old version to the new version. This method should be called
+   * when the new version is fully ready.
+   */
   public void activateIndex() {
     indexes.setSearchIndex(index);
     logger.atInfo().log("Using %s schema version %s", name, version(index));
diff --git a/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java b/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
index 8bf99a5..1b5404b 100644
--- a/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
+++ b/java/com/google/gerrit/server/index/ReindexerAlreadyRunningException.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+/** Exception to be thrown when attempting to start a second reindex job. */
 public class ReindexerAlreadyRunningException extends Exception {
 
   private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/server/index/SingleVersionModule.java b/java/com/google/gerrit/server/index/SingleVersionModule.java
index e3f9d7a..50dc4e9 100644
--- a/java/com/google/gerrit/server/index/SingleVersionModule.java
+++ b/java/com/google/gerrit/server/index/SingleVersionModule.java
@@ -33,6 +33,10 @@
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * Module that installs a listener to Gerrit's lifecycle events to specify which index versions to
+ * use.
+ */
 @Singleton
 public class SingleVersionModule extends LifecycleModule {
   public static final String SINGLE_VERSIONS = "IndexModule/SingleVersions";
@@ -51,6 +55,7 @@
         .toProvider(Providers.of(singleVersions));
   }
 
+  /** Listener to Gerrit's lifecycle events to specify which index versions to use. */
   @Singleton
   public static class SingleVersionListener implements LifecycleListener {
     private final Set<String> disabled;
diff --git a/java/com/google/gerrit/server/index/StalenessCheckResult.java b/java/com/google/gerrit/server/index/StalenessCheckResult.java
new file mode 100644
index 0000000..fe35e6e
--- /dev/null
+++ b/java/com/google/gerrit/server/index/StalenessCheckResult.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.FormatMethod;
+import java.util.Optional;
+
+/** Structured result of a staleness check. */
+@AutoValue
+public abstract class StalenessCheckResult {
+
+  public static StalenessCheckResult notStale() {
+    return new AutoValue_StalenessCheckResult(false, Optional.empty());
+  }
+
+  public static StalenessCheckResult stale(String reason) {
+    return new AutoValue_StalenessCheckResult(true, Optional.of(reason));
+  }
+
+  @FormatMethod
+  public static StalenessCheckResult stale(String reason, Object... args) {
+    return stale(String.format(reason, args));
+  }
+
+  public abstract boolean isStale();
+
+  public abstract Optional<String> reason();
+}
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index 3417379..56ce604 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -37,6 +37,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
+/** Trigger for online reindexing in case the index version in use is not the latest. */
 public abstract class VersionManager implements LifecycleListener {
   public static boolean getOnlineUpgrade(Config cfg) {
     return cfg.getBoolean("index", null, "onlineUpgrade", true);
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index 1838edf..ca7264c 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -21,9 +21,12 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.account.AccountPredicates;
 
+/**
+ * Index for Gerrit accounts. This class is mainly used for typing the generic parent class that
+ * contains actual implementations.
+ */
 public interface AccountIndex extends Index<Account.Id, AccountState> {
-  public interface Factory
-      extends IndexDefinition.IndexFactory<Account.Id, AccountState, AccountIndex> {}
+  interface Factory extends IndexDefinition.IndexFactory<Account.Id, AccountState, AccountIndex> {}
 
   @Override
   default Predicate<AccountState> keyPredicate(Account.Id id) {
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
index eb1f784..9d03e3b 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Singleton;
 
+/** Collection of active account indices. See {@link IndexCollection} for details on collections. */
 @Singleton
 public class AccountIndexCollection
     extends IndexCollection<Account.Id, AccountState, AccountIndex> {
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java b/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
index 3a34d47e..efc9c67 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Inject;
 
+/** Bundle of service classes that make up the account index. */
 public class AccountIndexDefinition
     extends IndexDefinition<Account.Id, AccountState, AccountIndex> {
 
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 643c249..8b95f7b 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -28,6 +28,7 @@
 import com.google.inject.Singleton;
 import org.eclipse.jgit.util.MutableInteger;
 
+/** Rewriter for the account index. See {@link IndexRewriter} for details. */
 @Singleton
 public class AccountIndexRewriter implements IndexRewriter<AccountState> {
   private final AccountIndexCollection indexes;
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexer.java b/java/com/google/gerrit/server/index/account/AccountIndexer.java
index 8ced005..a055113 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 
+/** Interface for indexing a Gerrit account. */
 public interface AccountIndexer {
 
   /**
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index b908846..025a1f9 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -34,9 +35,14 @@
 import java.util.Collections;
 import java.util.Optional;
 
+/**
+ * Implementation for indexing a Gerrit account. The account will be loaded from {@link
+ * AccountCache}.
+ */
 public class AccountIndexerImpl implements AccountIndexer {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /** Factory for creating an instance. */
   public interface Factory {
     AccountIndexerImpl create(AccountIndexCollection indexes);
 
@@ -97,6 +103,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.replace(accountState.get());
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to replace account %d in index version %d",
+                  id.get(), i.getSchema().getVersion()),
+              e);
         }
       } else {
         try (TraceTimer traceTimer =
@@ -107,6 +119,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.delete(id);
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to delete account %d from index version %d",
+                  id.get(), i.getSchema().getVersion()),
+              e);
         }
       }
     }
@@ -116,7 +134,9 @@
   @Override
   public boolean reindexIfStale(Account.Id id) {
     try {
-      if (stalenessChecker.isStale(id)) {
+      StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
+      if (stalenessCheckResult.isStale()) {
+        logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
         index(id);
         return true;
       }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 157e290..5de3ba4 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountState;
 
+/** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
   @Deprecated
   static final Schema<AccountState> V4 =
@@ -62,7 +63,12 @@
           .legacyNumericFields(false)
           .build();
 
+  /**
+   * Name of the account index to be used when contacting index backends or loading configurations.
+   */
   public static final String NAME = "accounts";
+
+  /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
 
   private AccountSchemaDefinitions() {
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index c1077b1..5f498f9 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -39,6 +39,10 @@
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 
+/**
+ * Implementation that can index all accounts on a host. Used by Gerrit's initialization and upgrade
+ * programs as well as by REST API endpoints that offer this functionality.
+ */
 @Singleton
 public class AllAccountsIndexer extends SiteIndexer<Account.Id, AccountState, AccountIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -67,7 +71,7 @@
       ids = collectAccounts(progress);
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error collecting accounts");
-      return new SiteIndexer.Result(sw, false, 0, 0);
+      return SiteIndexer.Result.create(sw, false, 0, 0);
     }
     return reindexAccounts(index, ids, progress);
   }
@@ -109,11 +113,11 @@
       Futures.successfulAsList(futures).get();
     } catch (ExecutionException | InterruptedException e) {
       logger.atSevere().withCause(e).log("Error waiting on account futures");
-      return new SiteIndexer.Result(sw, false, 0, 0);
+      return SiteIndexer.Result.create(sw, false, 0, 0);
     }
 
     progress.endTask();
-    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
+    return SiteIndexer.Result.create(sw, ok.get(), done.get(), failed.get());
   }
 
   private List<Account.Id> collectAccounts(ProgressMonitor progress) throws IOException {
diff --git a/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
index b3d961a0..d8ecbd9 100644
--- a/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
+++ b/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -26,6 +26,10 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.account.AccountState;
 
+/**
+ * Wrapper around {@link Predicate}s that are returned by the {@link
+ * com.google.gerrit.index.IndexRewriter}. See {@link IndexedQuery}.
+ */
 public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
     implements DataSource<AccountState>, Matchable<AccountState> {
 
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index aad9527..50fdcde 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -87,16 +88,16 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Account.Id id) throws IOException {
+  public StalenessCheckResult check(Account.Id id) throws IOException {
     AccountIndex i = indexes.getSearchIndex();
     if (i == null) {
       // No index; caller couldn't do anything if it is stale.
-      return false;
+      return StalenessCheckResult.notStale();
     }
     if (!i.getSchema().hasField(AccountField.REF_STATE)
         || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
       // Index version not new enough for this check.
-      return false;
+      return StalenessCheckResult.notStale();
     }
 
     boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
@@ -112,7 +113,11 @@
         Ref ref = repo.exactRef(RefNames.refsUsers(id));
 
         // Stale if the account actually exists.
-        return ref != null;
+        if (ref == null) {
+          return StalenessCheckResult.notStale();
+        }
+        return StalenessCheckResult.stale(
+            "Document missing in index, but found %s in the repo", ref);
       }
     }
 
@@ -124,8 +129,9 @@
           e.getKey().get().equals(AllUsersNameProvider.DEFAULT) ? allUsersName : e.getKey();
       try (Repository repo = repoManager.openRepository(repoName)) {
         if (!e.getValue().match(repo)) {
-          // Ref was modified since the account was indexed.
-          return true;
+          return StalenessCheckResult.stale(
+              "Ref was modified since the account was indexed (%s != %s)",
+              e.getValue(), repo.exactRef(e.getValue().ref()));
         }
       }
     }
@@ -134,17 +140,22 @@
     ListMultimap<ObjectId, ObjectId> extIdStates =
         parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
     if (extIdStates.size() != extIds.size()) {
-      // External IDs of the account were modified since the account was indexed.
-      return true;
+      return StalenessCheckResult.stale(
+          "External IDs of the account were modified since the account was indexed. (%s != %s)",
+          extIdStates.size(), extIds.size());
     }
     for (ExternalId extId : extIds) {
+      if (!extIdStates.containsKey(extId.key().sha1())) {
+        return StalenessCheckResult.stale("External ID missing: %s", extId.key().sha1());
+      }
       if (!extIdStates.containsEntry(extId.key().sha1(), extId.blobId())) {
-        // External IDs of the account were modified since the account was indexed.
-        return true;
+        return StalenessCheckResult.stale(
+            "External ID has unexpected value. (%s != %s)",
+            extIdStates.get(extId.key().sha1()), extId.blobId());
       }
     }
 
-    return false;
+    return StalenessCheckResult.notStale();
   }
 
   public static ListMultimap<ObjectId, ObjectId> parseExternalIdStates(
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 4f23e88..2b826ac 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -55,6 +55,11 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 
+/**
+ * Implementation that can index all changes on a host or within a project. Used by Gerrit's
+ * initialization and upgrade programs as well as by REST API endpoints that offer this
+ * functionality.
+ */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -118,7 +123,7 @@
         projectsFailed++;
         if (projectsFailed > projects.size() / 2) {
           logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
-          return new Result(sw, false, 0, 0);
+          return Result.create(sw, false, 0, 0);
         }
       }
       pm.update(1);
@@ -187,7 +192,7 @@
           nFailed, nTotal, Math.round(pctFailed));
       ok.set(false);
     }
-    return new Result(sw, ok.get(), nDone, nFailed);
+    return Result.create(sw, ok.get(), nDone, nFailed);
   }
 
   public Callable<Void> reindexProject(
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 07e9b9e4..d3a0065 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -269,6 +269,24 @@
   public static final FieldDef<ChangeData, Integer> OWNER =
       integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
 
+  /** References the source change number that this change was cherry-picked from. */
+  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
+      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
+          .build(
+              cd ->
+                  cd.change().getCherryPickOf() != null
+                      ? cd.change().getCherryPickOf().changeId().get()
+                      : null);
+
+  /** References the source change patch-set that this change was cherry-picked from. */
+  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET =
+      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET)
+          .build(
+              cd ->
+                  cd.change().getCherryPickOf() != null
+                      ? cd.change().getCherryPickOf().get()
+                      : null);
+
   /** The user assigned to the change. */
   public static final FieldDef<ChangeData, Integer> ASSIGNEE =
       integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 29bff0a..49d0d4e 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -22,9 +22,12 @@
 import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
 import com.google.gerrit.server.query.change.LegacyChangeIdStrPredicate;
 
+/**
+ * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
+ * contains actual implementations.
+ */
 public interface ChangeIndex extends Index<Change.Id, ChangeData> {
-  public interface Factory
-      extends IndexDefinition.IndexFactory<Change.Id, ChangeData, ChangeIndex> {}
+  interface Factory extends IndexDefinition.IndexFactory<Change.Id, ChangeData, ChangeIndex> {}
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
index b8e2f3e..817ec0e 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Singleton;
 
+/** Collection of active change indices. See {@link IndexCollection} for details on collections. */
 @Singleton
 public class ChangeIndexCollection extends IndexCollection<Change.Id, ChangeData, ChangeIndex> {
   @VisibleForTesting
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
index 7de9e74..dde9d86 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 
+/** Bundle of service classes that make up the change index. */
 public class ChangeIndexDefinition extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
 
   @Inject
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index f6d86bf..e0f6bec 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -24,10 +24,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -207,6 +209,12 @@
                   .indexVersion(i.getSchema().getVersion())
                   .build())) {
         i.replace(cd);
+      } catch (RuntimeException e) {
+        throw new StorageException(
+            String.format(
+                "Failed to replace change %d in index version %d (current patch set = %d)",
+                cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
+            e);
       }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
@@ -416,6 +424,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.delete(id);
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to delete change %d from index version %d",
+                  id.get(), i.getSchema().getVersion()),
+              e);
         }
       }
       fireChangeDeletedFromIndexEvent(id.get());
@@ -432,7 +446,9 @@
     public Boolean callImpl() throws Exception {
       remove();
       try {
-        if (stalenessChecker.isStale(id)) {
+        StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
+        if (stalenessCheckResult.isStale()) {
+          logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
           indexImpl(changeDataFactory.create(project, id));
           return true;
         }
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 72153c4..6d6d4b8 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
 
+/** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
   static final Schema<ChangeData> V55 =
@@ -90,6 +91,7 @@
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
   // document id type is replaced with string document id type.
+  @Deprecated
   static final Schema<ChangeData> V57 =
       new Schema.Builder<ChangeData>()
           .add(V56)
@@ -98,7 +100,20 @@
           .legacyNumericFields(false)
           .build();
 
+  // Add new field CHERRY_PICK_OF
+  static final Schema<ChangeData> V58 =
+      new Schema.Builder<ChangeData>()
+          .add(V57)
+          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
+          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
+          .build();
+
+  /**
+   * Name of the change index to be used when contacting index backends or loading configurations.
+   */
   public static final String NAME = "changes";
+
+  /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
 
   private ChangeSchemaDefinitions() {
diff --git a/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
deleted file mode 100644
index ae3b729..0000000
--- a/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
-
-public class DummyChangeIndex implements ChangeIndex {
-  @Override
-  public Schema<ChangeData> getSchema() {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void close() {}
-
-  @Override
-  public void replace(ChangeData cd) {}
-
-  @Override
-  public void delete(Change.Id id) {}
-
-  @Override
-  public void deleteAll() {}
-
-  @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
-  public void markReady(boolean ready) {}
-
-  public int getMaxLimit() {
-    return Integer.MAX_VALUE;
-  }
-}
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index f6d3b6f..19bb423 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider.QueueType;
@@ -44,6 +45,15 @@
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * Listener for ref update events that reindexes entities in case the updated Git reference was used
+ * to compute contents of an index document.
+ *
+ * <p>Reindexes any open changes that has a destination branch that was updated to ensure that
+ * 'mergeable' is still current.
+ *
+ * <p>Will reindex accounts when the account's NoteDb ref changes.
+ */
 public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -76,7 +86,7 @@
     this.accountCache = accountCache;
     this.indexer = indexer;
     this.executor = executor;
-    this.enabled = cfg.getBoolean("index", null, "reindexAfterRefUpdate", true);
+    this.enabled = MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 47fd7ba..a1c6286 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -46,6 +47,10 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
+/**
+ * Checker that compares values stored in the change index to metadata in NoteDb to detect index
+ * documents that should have been updated (= stale).
+ */
 @Singleton
 public class StalenessChecker {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -68,27 +73,36 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Change.Id id) {
+  /**
+   * Returns a {@link StalenessCheckResult} with structured information about staleness of the
+   * provided {@link com.google.gerrit.entities.Change.Id}.
+   */
+  public StalenessCheckResult check(Change.Id id) {
     ChangeIndex i = indexes.getSearchIndex();
     if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
+      return StalenessCheckResult
+          .notStale(); // No index; caller couldn't do anything if it is stale.
     }
     if (!i.getSchema().hasField(ChangeField.REF_STATE)
         || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
-      return false; // Index version not new enough for this check.
+      return StalenessCheckResult.notStale(); // Index version not new enough for this check.
     }
 
     Optional<ChangeData> result =
         i.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, FIELDS));
     if (!result.isPresent()) {
-      return true; // Not in index, but caller wants it to be.
+      return StalenessCheckResult.stale("Document %s missing from index", id);
     }
     ChangeData cd = result.get();
-    return isStale(repoManager, id, parseStates(cd), parsePatterns(cd));
+    return check(repoManager, id, parseStates(cd), parsePatterns(cd));
   }
 
+  /**
+   * Returns a {@link StalenessCheckResult} with structured information about staleness of the
+   * provided change.
+   */
   @UsedAt(UsedAt.Project.GOOGLE)
-  public static boolean isStale(
+  public static StalenessCheckResult check(
       GitRepositoryManager repoManager,
       Change.Id id,
       SetMultimap<Project.NameKey, RefState> states,
@@ -97,7 +111,7 @@
   }
 
   @VisibleForTesting
-  static boolean refsAreStale(
+  static StalenessCheckResult refsAreStale(
       GitRepositoryManager repoManager,
       Change.Id id,
       SetMultimap<Project.NameKey, RefState> states,
@@ -105,12 +119,13 @@
     Set<Project.NameKey> projects = Sets.union(states.keySet(), patterns.keySet());
 
     for (Project.NameKey p : projects) {
-      if (refsAreStale(repoManager, id, p, states, patterns)) {
-        return true;
+      StalenessCheckResult result = refsAreStale(repoManager, id, p, states, patterns);
+      if (result.isStale()) {
+        return result;
       }
     }
 
-    return false;
+    return StalenessCheckResult.notStale();
   }
 
   private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
@@ -121,6 +136,10 @@
     return parsePatterns(cd.getRefStatePatterns());
   }
 
+  /**
+   * Returns a map containing the parsed version of {@link RefStatePattern}. See {@link
+   * RefStatePattern}.
+   */
   public static ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(
       Iterable<byte[]> patterns) {
     RefStatePattern.check(patterns != null, null);
@@ -136,7 +155,7 @@
     return result;
   }
 
-  private static boolean refsAreStale(
+  private static StalenessCheckResult refsAreStale(
       GitRepositoryManager repoManager,
       Change.Id id,
       Project.NameKey project,
@@ -146,18 +165,22 @@
       Set<RefState> states = allStates.get(project);
       for (RefState state : states) {
         if (!state.match(repo)) {
-          return true;
+          return StalenessCheckResult.stale(
+              "Ref states don't match for document %s (%s != %s)",
+              id, state, repo.exactRef(state.ref()));
         }
       }
       for (RefStatePattern pattern : allPatterns.get(project)) {
         if (!pattern.match(repo, states)) {
-          return true;
+          return StalenessCheckResult.stale(
+              "Ref patterns don't match for document %s. Pattern: %s States: %s",
+              id, pattern, states);
         }
       }
-      return false;
+      return StalenessCheckResult.notStale();
     } catch (IOException e) {
       logger.atWarning().withCause(e).log("error checking staleness of %s in %s", id, project);
-      return true;
+      return StalenessCheckResult.stale("Exceptions while processing document %s", e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 0dbbbc5..51c7730 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -43,6 +43,10 @@
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 
+/**
+ * Implementation that can index all groups on a host. Used by Gerrit's initialization and upgrade
+ * programs as well as by REST API endpoints that offer this functionality.
+ */
 @Singleton
 public class AllGroupsIndexer extends SiteIndexer<AccountGroup.UUID, InternalGroup, GroupIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -71,7 +75,7 @@
       uuids = collectGroups(progress);
     } catch (IOException | ConfigInvalidException e) {
       logger.atSevere().withCause(e).log("Error collecting groups");
-      return new SiteIndexer.Result(sw, false, 0, 0);
+      return SiteIndexer.Result.create(sw, false, 0, 0);
     }
     return reindexGroups(index, uuids, progress);
   }
@@ -117,11 +121,11 @@
       Futures.successfulAsList(futures).get();
     } catch (ExecutionException | InterruptedException e) {
       logger.atSevere().withCause(e).log("Error waiting on group futures");
-      return new SiteIndexer.Result(sw, false, 0, 0);
+      return SiteIndexer.Result.create(sw, false, 0, 0);
     }
 
     progress.endTask();
-    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
+    return SiteIndexer.Result.create(sw, ok.get(), done.get(), failed.get());
   }
 
   private List<AccountGroup.UUID> collectGroups(ProgressMonitor progress)
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index daa2131..be569df 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -21,8 +21,12 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.query.group.GroupPredicates;
 
+/**
+ * Index for internal Gerrit groups. This class is mainly used for typing the generic parent class
+ * that contains actual implementations.
+ */
 public interface GroupIndex extends Index<AccountGroup.UUID, InternalGroup> {
-  public interface Factory
+  interface Factory
       extends IndexDefinition.IndexFactory<AccountGroup.UUID, InternalGroup, GroupIndex> {}
 
   @Override
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
index 9d74b7d..6c36a97 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Singleton;
 
+/** Collection of active group indices. See {@link IndexCollection} for details on collections. */
 @Singleton
 public class GroupIndexCollection
     extends IndexCollection<AccountGroup.UUID, InternalGroup, GroupIndex> {
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
index e403752..a9cd856 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 
+/** Bundle of service classes that make up the group index. */
 public class GroupIndexDefinition
     extends IndexDefinition<AccountGroup.UUID, InternalGroup, GroupIndex> {
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
index 157c01a..a7b9497 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
@@ -24,6 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/** Rewriter for the group index. See {@link IndexRewriter} for details. */
 @Singleton
 public class GroupIndexRewriter implements IndexRewriter<InternalGroup> {
   private final GroupIndexCollection indexes;
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexer.java b/java/com/google/gerrit/server/index/group/GroupIndexer.java
index 25d5840..d6b9186 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexer.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.AccountGroup;
 
+/** Interface for indexing an internal Gerrit group. */
 public interface GroupIndexer {
 
   /**
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index 790066d..e9897e8 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -34,6 +35,10 @@
 import java.util.Collections;
 import java.util.Optional;
 
+/**
+ * Implementation for indexing an internal Gerrit group. The group will be loaded from {@link
+ * GroupCache}.
+ */
 public class GroupIndexerImpl implements GroupIndexer {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -97,6 +102,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.replace(internalGroup.get());
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to replace group %s in index version %d",
+                  uuid.get(), i.getSchema().getVersion()),
+              e);
         }
       } else {
         try (TraceTimer traceTimer =
@@ -107,6 +118,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.delete(uuid);
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to delete group %s from index version %d",
+                  uuid.get(), i.getSchema().getVersion()),
+              e);
         }
       }
     }
@@ -116,7 +133,9 @@
   @Override
   public boolean reindexIfStale(AccountGroup.UUID uuid) {
     try {
-      if (stalenessChecker.isStale(uuid)) {
+      StalenessCheckResult stalenessCheckResult = stalenessChecker.check(uuid);
+      if (stalenessCheckResult.isStale()) {
+        logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
         index(uuid);
         return true;
       }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index e64e2eb..40e0d8e 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.group.InternalGroup;
 
+/** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
   static final Schema<InternalGroup> V2 =
@@ -49,6 +50,7 @@
   // to offer fast single- and multi-dimensional numeric range.
   static final Schema<InternalGroup> V8 = schema(V7, false);
 
+  /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
 
   private GroupSchemaDefinitions() {
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index dfdf3ca..319b834 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -26,6 +26,10 @@
 import java.util.HashSet;
 import java.util.Set;
 
+/**
+ * Wrapper around {@link Predicate}s that are returned by the {@link
+ * com.google.gerrit.index.IndexRewriter}. See {@link IndexedQuery}.
+ */
 public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, InternalGroup>
     implements DataSource<InternalGroup> {
 
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 3a721c3..4ce3f5b 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -34,8 +35,6 @@
  *
  * <p>An index document is considered stale if the stored SHA1 differs from the HEAD SHA1 of the
  * groups branch.
- *
- * <p>Note: This only applies to NoteDb.
  */
 @Singleton
 public class StalenessChecker {
@@ -59,10 +58,11 @@
     this.allUsers = allUsers;
   }
 
-  public boolean isStale(AccountGroup.UUID uuid) throws IOException {
+  public StalenessCheckResult check(AccountGroup.UUID uuid) throws IOException {
     GroupIndex i = indexes.getSearchIndex();
     if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
+      // No index; caller couldn't do anything if it is stale.
+      return StalenessCheckResult.notStale();
     }
 
     Optional<FieldBundle> result =
@@ -73,14 +73,23 @@
         Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
 
         // Stale if the group actually exists.
-        return ref != null;
+        if (ref == null) {
+          return StalenessCheckResult.notStale();
+        }
+        return StalenessCheckResult.stale(
+            "Document missing in index, but found %s in the repo", ref);
       }
     }
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
       ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
-      return !head.equals(ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0));
+      ObjectId idFromIndex = ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0);
+      if (head.equals(idFromIndex)) {
+        return StalenessCheckResult.notStale();
+      }
+      return StalenessCheckResult.stale(
+          "Document has unexpected ref state (%s != %s)", head, idFromIndex);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index b760fd7..2c37716 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -37,6 +37,10 @@
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 
+/**
+ * Implementation that can index all projects on a host. Used by Gerrit's initialization and upgrade
+ * programs as well as by REST API endpoints that offer this functionality.
+ */
 @Singleton
 public class AllProjectsIndexer extends SiteIndexer<Project.NameKey, ProjectData, ProjectIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -91,11 +95,11 @@
       Futures.successfulAsList(futures).get();
     } catch (ExecutionException | InterruptedException e) {
       logger.atSevere().withCause(e).log("Error waiting on project futures");
-      return new SiteIndexer.Result(sw, false, 0, 0);
+      return SiteIndexer.Result.create(sw, false, 0, 0);
     }
 
     progress.endTask();
-    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
+    return SiteIndexer.Result.create(sw, ok.get(), done.get(), failed.get());
   }
 
   private List<Project.NameKey> collectProjects(ProgressMonitor progress) {
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java b/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
index 6a844b5..d93dfe7 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.inject.Inject;
 
+/** Bundle of service classes that make up the project index. */
 public class ProjectIndexDefinition
     extends IndexDefinition<Project.NameKey, ProjectData, ProjectIndex> {
 
diff --git a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
index 4de83be..22517ad 100644
--- a/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -18,6 +18,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
@@ -34,6 +35,10 @@
 import java.util.Collection;
 import java.util.Collections;
 
+/**
+ * Implementation for indexing a Gerrit-managed repository (project). The project will be loaded
+ * from {@link ProjectCache}.
+ */
 public class ProjectIndexerImpl implements ProjectIndexer {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -85,6 +90,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.replace(projectData);
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to replace project %s in index version %d",
+                  nameKey.get(), i.getSchema().getVersion()),
+              e);
         }
       }
       fireProjectIndexedEvent(nameKey.get());
@@ -99,6 +110,12 @@
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
           i.delete(nameKey);
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to delete project %s from index version %d",
+                  nameKey.get(), i.getSchema().getVersion()),
+              e);
         }
       }
     }
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index e4c1a7d..1e10b7c 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -27,10 +27,15 @@
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import java.util.Optional;
 
+/**
+ * Checker that compares values stored in the project index to metadata in NoteDb to detect index
+ * documents that should have been updated (= stale).
+ */
 public class StalenessChecker {
   private static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
@@ -47,17 +52,22 @@
     this.indexConfig = indexConfig;
   }
 
-  public boolean isStale(Project.NameKey project) {
+  /**
+   * Returns a {@link StalenessCheckResult} with structured information about staleness of the
+   * provided {@link com.google.gerrit.entities.Project.NameKey}.
+   */
+  public StalenessCheckResult check(Project.NameKey project) {
     ProjectData projectData = projectCache.get(project).toProjectData();
     ProjectIndex i = indexes.getSearchIndex();
     if (i == null) {
-      return false; // No index; caller couldn't do anything if it is stale.
+      return StalenessCheckResult
+          .notStale(); // No index; caller couldn't do anything if it is stale.
     }
 
     Optional<FieldBundle> result =
         i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
     if (!result.isPresent()) {
-      return true;
+      return StalenessCheckResult.stale("Document %s missing from index", project);
     }
 
     SetMultimap<Project.NameKey, RefState> indexedRefStates =
@@ -73,6 +83,10 @@
                     p.getProject().getNameKey(),
                     RefState.create(RefNames.REFS_CONFIG, p.getProject().getConfigRefState())));
 
-    return !currentRefStates.equals(indexedRefStates);
+    if (currentRefStates.equals(indexedRefStates)) {
+      return StalenessCheckResult.notStale();
+    }
+    return StalenessCheckResult.stale(
+        "Document has unexpected ref states (%s != %s)", currentRefStates, indexedRefStates);
   }
 }
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 7af204e..3bb4770 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -59,6 +59,9 @@
   // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
   public abstract Optional<String> changeIdType();
 
+  // The cause of an error.
+  public abstract Optional<String> cause();
+
   // The type of an event.
   public abstract Optional<String> eventType();
 
@@ -113,9 +116,6 @@
   // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
   public abstract Optional<String> noteDbSequenceType();
 
-  // Name of a "table" in NoteDb (if set, always CHANGES).
-  public abstract Optional<String> noteDbTable();
-
   // The ID of a patch set.
   public abstract Optional<Integer> patchSetId();
 
@@ -155,16 +155,16 @@
    * Metadata{accountId=Optional.empty, actionType=Optional.empty, authDomainName=Optional.empty,
    * branchName=Optional.empty, cacheKey=Optional.empty, cacheName=Optional.empty,
    * className=Optional.empty, changeId=Optional[9212550], changeIdType=Optional.empty,
-   * eventType=Optional.empty, exportValue=Optional.empty, filePath=Optional.empty,
-   * garbageCollectorName=Optional.empty, gitOperation=Optional.empty, groupId=Optional.empty,
-   * groupName=Optional.empty, groupUuid=Optional.empty, httpStatus=Optional.empty,
-   * indexName=Optional.empty, indexVersion=Optional[0], methodName=Optional.empty,
-   * multiple=Optional.empty, operationName=Optional.empty, partial=Optional.empty,
-   * noteDbFilePath=Optional.empty, noteDbRefName=Optional.empty,
-   * noteDbSequenceType=Optional.empty, noteDbTable=Optional.empty, patchSetId=Optional.empty,
-   * pluginMetadata=[], pluginName=Optional.empty, projectName=Optional.empty,
-   * pushType=Optional.empty, resourceCount=Optional.empty, restViewName=Optional.empty,
-   * revision=Optional.empty, username=Optional.empty}
+   * cause=Optional.empty, eventType=Optional.empty, exportValue=Optional.empty,
+   * filePath=Optional.empty, garbageCollectorName=Optional.empty, gitOperation=Optional.empty,
+   * groupId=Optional.empty, groupName=Optional.empty, groupUuid=Optional.empty,
+   * httpStatus=Optional.empty, indexName=Optional.empty, indexVersion=Optional[0],
+   * methodName=Optional.empty, multiple=Optional.empty, operationName=Optional.empty,
+   * partial=Optional.empty, noteDbFilePath=Optional.empty, noteDbRefName=Optional.empty,
+   * noteDbSequenceType=Optional.empty, patchSetId=Optional.empty, pluginMetadata=[],
+   * pluginName=Optional.empty, projectName=Optional.empty, pushType=Optional.empty,
+   * resourceCount=Optional.empty, restViewName=Optional.empty, revision=Optional.empty,
+   * username=Optional.empty}
    * </pre>
    *
    * <p>That's hard to read in logs. This is why this method
@@ -273,6 +273,8 @@
 
     public abstract Builder changeIdType(@Nullable String changeIdType);
 
+    public abstract Builder cause(@Nullable String cause);
+
     public abstract Builder eventType(@Nullable String eventType);
 
     public abstract Builder exportValue(@Nullable String exportValue);
@@ -309,8 +311,6 @@
 
     public abstract Builder noteDbSequenceType(@Nullable String noteDbSequenceType);
 
-    public abstract Builder noteDbTable(@Nullable String noteDbTable);
-
     public abstract Builder patchSetId(int patchSetId);
 
     abstract ImmutableList.Builder<PluginMetadata> pluginMetadataBuilder();
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index 1040a55..ff22d23 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -33,6 +34,7 @@
 import org.eclipse.jgit.revwalk.FooterLine;
 
 public class MailUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static MailRecipients getRecipientsFromFooters(
       AccountResolver accountResolver, List<FooterLine> footerLines)
@@ -41,11 +43,19 @@
     for (FooterLine footerLine : footerLines) {
       try {
         if (isReviewer(footerLine)) {
-          recipients.reviewers.add(toAccountId(accountResolver, footerLine.getValue().trim()));
+          Account.Id accountId = toAccountId(accountResolver, footerLine.getValue().trim());
+          recipients.reviewers.add(accountId);
+          logger.atFine().log(
+              "Added account %d from footer line \"%s\" as reviewer", accountId.get(), footerLine);
         } else if (footerLine.matches(FooterKey.CC)) {
-          recipients.cc.add(toAccountId(accountResolver, footerLine.getValue().trim()));
+          Account.Id accountId = toAccountId(accountResolver, footerLine.getValue().trim());
+          recipients.cc.add(accountId);
+          logger.atFine().log(
+              "Added account %d from footer line \"%s\" as cc", accountId.get(), footerLine);
         }
       } catch (UnprocessableEntityException e) {
+        logger.atFine().log(
+            "Skip adding reviewer/cc from footer line \"%s\": %s", footerLine, e.getMessage());
         continue;
       }
     }
diff --git a/java/com/google/gerrit/server/mail/SignedToken.java b/java/com/google/gerrit/server/mail/SignedToken.java
index 436b854..ba064df 100644
--- a/java/com/google/gerrit/server/mail/SignedToken.java
+++ b/java/com/google/gerrit/server/mail/SignedToken.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.common.io.BaseEncoding;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
@@ -21,7 +22,6 @@
 import javax.crypto.Mac;
 import javax.crypto.ShortBufferException;
 import javax.crypto.spec.SecretKeySpec;
-import org.apache.commons.codec.binary.Base64;
 
 /**
  * Utility function to compute and verify XSRF tokens.
@@ -164,11 +164,11 @@
   }
 
   private static byte[] decodeBase64(final String s) {
-    return Base64.decodeBase64(toBytes(s));
+    return BaseEncoding.base64().decode(s);
   }
 
   private static String encodeBase64(final byte[] buf) {
-    return toString(Base64.encodeBase64(buf));
+    return BaseEncoding.base64().encode(buf);
   }
 
   private static void encodeInt(final byte[] buf, final int o, final int v) {
@@ -202,12 +202,4 @@
     }
     return r;
   }
-
-  private static String toString(final byte[] b) {
-    final StringBuilder r = new StringBuilder(b.length);
-    for (int i = 0; i < b.length; i++) {
-      r.append((char) b[i]);
-    }
-    return r.toString();
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 8b5cc92..8ae06f8 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
@@ -25,7 +26,6 @@
 import com.google.inject.Singleton;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import org.eclipse.jgit.util.Base64;
 
 /** Verifies the token sent by {@link RegisterNewEmailSender}. */
 @Singleton
@@ -50,7 +50,7 @@
     try {
       String payload = String.format("%s:%s", accountId, emailAddress);
       byte[] utf8 = payload.getBytes(UTF_8);
-      String base64 = Base64.encodeBytes(utf8);
+      String base64 = BaseEncoding.base64().encode(utf8);
       return emailRegistrationToken.newToken(base64);
     } catch (XsrfException e) {
       throw new IllegalArgumentException(e);
@@ -70,7 +70,7 @@
       throw new InvalidTokenException();
     }
 
-    String payload = new String(Base64.decode(token.getData()), UTF_8);
+    String payload = new String(BaseEncoding.base64().decode(token.getData()), UTF_8);
     Matcher matcher = Pattern.compile("^([0-9]+):(.+@.+)$").matcher(payload);
     if (!matcher.matches()) {
       throw new InvalidTokenException();
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 158db1c..9c3dd02 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -34,6 +35,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.mail.HtmlParser;
@@ -156,11 +158,14 @@
    * @param message {@link MailMessage} to process
    */
   public void process(MailMessage message) throws RestApiException, UpdateException {
-    retryHelper.execute(
-        buf -> {
-          processImpl(buf, message);
-          return null;
-        });
+    retryHelper
+        .changeUpdate(
+            "processCommentsReceivedByEmail",
+            buf -> {
+              processImpl(buf, message);
+              return null;
+            })
+        .call();
   }
 
   private void processImpl(BatchUpdate.Factory buf, MailMessage message)
@@ -239,7 +244,7 @@
         sendRejectionEmail(message, InboundEmailRejectionSender.Error.INTERNAL_EXCEPTION);
         return;
       }
-      ChangeData cd = changeDataList.get(0);
+      ChangeData cd = Iterables.getOnlyElement(changeDataList);
       if (existingMessageIds(cd).contains(message.id())) {
         logger.atInfo().log("Message %s was already processed. Will delete message.", message.id());
         return;
@@ -281,11 +286,17 @@
               .map(
                   comment ->
                       CommentForValidation.create(
+                          CommentForValidation.CommentSource.HUMAN,
                           MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE.get(comment.getType()),
-                          comment.getMessage()))
+                          comment.getMessage(),
+                          comment.getMessage().length()))
               .collect(ImmutableList.toImmutableList());
+      CommentValidationContext commentValidationCtx =
+          CommentValidationContext.create(
+              cd.change().getChangeId(), cd.change().getProject().get());
       ImmutableList<CommentValidationFailure> commentValidationFailures =
-          PublishCommentUtil.findInvalidComments(commentValidators, parsedCommentsForValidation);
+          PublishCommentUtil.findInvalidComments(
+              commentValidationCtx, commentValidators, parsedCommentsForValidation);
       if (!commentValidationFailures.isEmpty()) {
         sendRejectionEmail(message, InboundEmailRejectionSender.Error.COMMENT_REJECTED);
         return;
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 8b3d3f7..35b0027 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -24,6 +24,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
 
+/** Sender that informs a user by email about the addition of an SSH or GPG key to their account. */
 public class AddKeySender extends OutgoingEmail {
   public interface Factory {
     AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
@@ -78,15 +79,26 @@
     }
   }
 
-  public String getEmail() {
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("gpgKeys", getGpgKeys());
+    soyContextEmailData.put("keyType", getKeyType());
+    soyContextEmailData.put("sshKey", getSshKey());
+    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+
+  private String getEmail() {
     return user.getAccount().preferredEmail();
   }
 
-  public String getUserNameEmail() {
-    return getUserNameEmailFor(user.getAccountId());
-  }
-
-  public String getKeyType() {
+  private String getKeyType() {
     if (sshKey != null) {
       return "SSH";
     } else if (gpgKeys != null) {
@@ -95,29 +107,14 @@
     return "Unknown";
   }
 
-  public String getSshKey() {
+  private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
-  public String getGpgKeys() {
+  private String getGpgKeys() {
     if (gpgKeys != null) {
       return Joiner.on("\n").join(gpgKeys);
     }
     return null;
   }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("gpgKeys", getGpgKeys());
-    soyContextEmailData.put("keyType", getKeyType());
-    soyContextEmailData.put("sshKey", getSshKey());
-    soyContextEmailData.put("userNameEmail", getUserNameEmail());
-  }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index c9bb1e4..c26e336 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -25,6 +25,9 @@
 import java.util.Collections;
 import java.util.List;
 
+/**
+ * Sender that informs a user by email about the removal of an SSH or GPG key from their account.
+ */
 public class DeleteKeySender extends OutgoingEmail {
   public interface Factory {
     DeleteKeySender create(IdentifiedUser user, AccountSshKey sshKey);
@@ -76,15 +79,26 @@
     }
   }
 
-  public String getEmail() {
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContextEmailData.put("email", getEmail());
+    soyContextEmailData.put("gpgKeyFingerprints", getGpgKeyFingerprints());
+    soyContextEmailData.put("keyType", getKeyType());
+    soyContextEmailData.put("sshKey", getSshKey());
+    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+
+  private String getEmail() {
     return user.getAccount().preferredEmail();
   }
 
-  public String getUserNameEmail() {
-    return getUserNameEmailFor(user.getAccountId());
-  }
-
-  public String getKeyType() {
+  private String getKeyType() {
     if (sshKey != null) {
       return "SSH";
     } else if (gpgKeyFingerprints != null) {
@@ -93,29 +107,14 @@
     throw new IllegalStateException("key type is not SSH or GPG");
   }
 
-  public String getSshKey() {
+  private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
-  public String getGpgKeyFingerprints() {
+  private String getGpgKeyFingerprints() {
     if (!gpgKeyFingerprints.isEmpty()) {
       return Joiner.on("\n").join(gpgKeyFingerprints);
     }
     return null;
   }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("gpgKeyFingerprints", getGpgKeyFingerprints());
-    soyContextEmailData.put("keyType", getKeyType());
-    soyContextEmailData.put("sshKey", getSshKey());
-    soyContextEmailData.put("userNameEmail", getUserNameEmail());
-  }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index ede5765..808d6a4 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -45,11 +45,24 @@
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+/**
+ * Arguments used for sending notification emails.
+ *
+ * <p>Notification emails are sent by out by {@link OutgoingEmail} and it's subclasses, so called
+ * senders. To construct an email the sender class needs to get various other classes injected.
+ * Instead of injecting these classes into the sender classes directly, they only get {@code
+ * EmailArguments} injected and {@code EmailArguments} provides them all dependencies that they
+ * need.
+ *
+ * <p>This class is public because plugins need access to it for sending emails.
+ */
+@Singleton
 @UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class EmailArguments {
   final GitRepositoryManager server;
@@ -60,22 +73,22 @@
   final PatchListCache patchListCache;
   final PatchSetUtil patchSetUtil;
   final ApprovalsUtil approvalsUtil;
-  final FromAddressGenerator fromAddressGenerator;
+  final Provider<FromAddressGenerator> fromAddressGenerator;
   final EmailSender emailSender;
   final PatchSetInfoFactory patchSetInfoFactory;
   final IdentifiedUser.GenericFactory identifiedUserFactory;
   final ChangeNotes.Factory changeNotesFactory;
-  final AnonymousUser anonymousUser;
+  final Provider<AnonymousUser> anonymousUser;
   final String anonymousCowardName;
-  final PersonIdent gerritPersonIdent;
+  final Provider<PersonIdent> gerritPersonIdent;
   final DynamicItem<UrlFormatter> urlFormatter;
   final AllProjectsName allProjectsName;
   final List<String> sshAddresses;
   final SitePaths site;
 
-  final ChangeQueryBuilder queryBuilder;
+  final Provider<ChangeQueryBuilder> queryBuilder;
   final ChangeData.Factory changeDataFactory;
-  final SoySauce soySauce;
+  final Provider<SoySauce> soySauce;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
   final Provider<InternalAccountQuery> accountQueryProvider;
@@ -93,19 +106,19 @@
       PatchListCache patchListCache,
       PatchSetUtil patchSetUtil,
       ApprovalsUtil approvalsUtil,
-      FromAddressGenerator fromAddressGenerator,
+      Provider<FromAddressGenerator> fromAddressGenerator,
       EmailSender emailSender,
       PatchSetInfoFactory patchSetInfoFactory,
       GenericFactory identifiedUserFactory,
       ChangeNotes.Factory changeNotesFactory,
-      AnonymousUser anonymousUser,
+      Provider<AnonymousUser> anonymousUser,
       @AnonymousCowardName String anonymousCowardName,
-      GerritPersonIdentProvider gerritPersonIdentProvider,
+      GerritPersonIdentProvider gerritPersonIdent,
       DynamicItem<UrlFormatter> urlFormatter,
       AllProjectsName allProjectsName,
-      ChangeQueryBuilder queryBuilder,
+      Provider<ChangeQueryBuilder> queryBuilder,
       ChangeData.Factory changeDataFactory,
-      @MailTemplates SoySauce soySauce,
+      @MailTemplates Provider<SoySauce> soySauce,
       EmailSettings settings,
       @SshAdvertisedAddresses List<String> sshAddresses,
       SitePaths site,
@@ -129,7 +142,7 @@
     this.changeNotesFactory = changeNotesFactory;
     this.anonymousUser = anonymousUser;
     this.anonymousCowardName = anonymousCowardName;
-    this.gerritPersonIdent = gerritPersonIdentProvider.get();
+    this.gerritPersonIdent = gerritPersonIdent;
     this.urlFormatter = urlFormatter;
     this.allProjectsName = allProjectsName;
     this.queryBuilder = queryBuilder;
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index bdfe2e8..6a34786 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.mail.Address;
@@ -32,7 +33,6 @@
 import java.security.NoSuchAlgorithmException;
 import java.util.Optional;
 import java.util.regex.Pattern;
-import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
@@ -232,7 +232,7 @@
     try {
       MessageDigest hash = MessageDigest.getInstance("MD5");
       byte[] bytes = hash.digest(data.getBytes(UTF_8));
-      return Base64.encodeBase64URLSafeString(bytes);
+      return BaseEncoding.base64Url().encode(bytes);
     } catch (NoSuchAlgorithmException e) {
       throw new RuntimeException("No MD5 available", e);
     }
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index 2db2d6d..d792b48 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -21,6 +21,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
+/** Sender that informs a user by email that the HTTP password of their account was updated. */
 public class HttpPasswordUpdateSender extends OutgoingEmail {
   public interface Factory {
     HttpPasswordUpdateSender create(IdentifiedUser user, String operation);
@@ -58,19 +59,11 @@
     }
   }
 
-  public String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  public String getUserNameEmail() {
-    return getUserNameEmailFor(user.getAccountId());
-  }
-
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
     soyContextEmailData.put("operation", operation);
   }
 
@@ -78,4 +71,8 @@
   protected boolean supportsHtml() {
     return true;
   }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index e588158..d48ed59 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -52,6 +52,7 @@
 import java.util.Set;
 import java.util.StringJoiner;
 import org.apache.james.mime4j.dom.field.FieldName;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
 /** Sends an email to one or more interested parties. */
@@ -127,11 +128,19 @@
             // this message so they can always review and audit what we sent
             // on their behalf to others.
             //
+            logger.atFine().log(
+                "CC email sender %s because the email strategy of this user is %s",
+                fromUser.get().account().id(), CC_ON_OWN_COMMENTS);
             add(RecipientType.CC, fromId);
           } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
             // If they don't want a copy, but we queued one up anyway,
             // drop them from the recipient lists.
             //
+            logger.atFine().log(
+                "Not CCing email sender %s because the email strategy of this user is not %s but %s",
+                fromUser.get().account().id(),
+                CC_ON_OWN_COMMENTS,
+                senderPrefs != null ? senderPrefs.getEmailStrategy() : null);
             removeUser(fromUser.get().account());
           }
         }
@@ -145,8 +154,12 @@
           Account thisUserAccount = thisUser.get().account();
           GeneralPreferencesInfo prefs = thisUser.get().generalPreferences();
           if (prefs == null || prefs.getEmailStrategy() == DISABLED) {
+            logger.atFine().log(
+                "Not emailing account %s because user has set email strategy to %s", id, DISABLED);
             removeUser(thisUserAccount);
           } else if (useHtml() && prefs.getEmailFormat() == EmailFormat.PLAINTEXT) {
+            logger.atFine().log(
+                "Removing account %s from HTML email because user prefers plain text emails", id);
             removeUser(thisUserAccount);
             smtpRcptToPlaintextOnly.add(
                 new Address(thisUserAccount.fullName(), thisUserAccount.preferredEmail()));
@@ -206,12 +219,13 @@
 
       if (!va.smtpRcptTo.isEmpty()) {
         // Send multipart message
-        logger.atFine().log("Sending multipart '%s'", messageClass);
+        logger.atFine().log(
+            "Sending multipart '%s' from %s to %s",
+            messageClass, va.smtpFromAddress, va.smtpRcptTo);
         args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
       }
 
       if (!smtpRcptToPlaintextOnly.isEmpty()) {
-        logger.atFine().log("Sending plaintext '%s'", messageClass);
         // Send plaintext message
         Map<String, EmailHeader> shallowCopy = new HashMap<>();
         shallowCopy.putAll(headers);
@@ -224,6 +238,9 @@
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
+        logger.atFine().log(
+            "Sending plaintext '%s' from %s to %s",
+            messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly);
         args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body);
       }
     }
@@ -240,7 +257,7 @@
   protected void init() throws EmailException {
     setupSoyContext();
 
-    smtpFromAddress = args.fromAddressGenerator.from(fromId);
+    smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
     setHeader(FieldName.DATE, new Date());
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
@@ -257,7 +274,7 @@
     textBody = new StringBuilder();
     htmlBody = new StringBuilder();
 
-    if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) {
+    if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
       appendText(getFromLine());
     }
   }
@@ -337,7 +354,7 @@
   /** Lookup a human readable name for an account, usually the "full name". */
   protected String getNameFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
-      return args.gerritPersonIdent.getName();
+      return args.gerritPersonIdent.get().getName();
     }
 
     Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account);
@@ -363,10 +380,8 @@
    */
   protected String getNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
-      return args.gerritPersonIdent.getName()
-          + " <"
-          + args.gerritPersonIdent.getEmailAddress()
-          + ">";
+      PersonIdent gerritIdent = args.gerritPersonIdent.get();
+      return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">";
     }
 
     Optional<Account> account = args.accountCache.get(accountId).map(AccountState::account);
@@ -575,7 +590,10 @@
 
   /** Configures a soy renderer for the given template name and rendering data map. */
   private SoySauce.Renderer configureRenderer(String templateName) {
-    return args.soySauce.renderTemplate(SOY_TEMPLATE_NAMESPACE + templateName).setData(soyContext);
+    return args.soySauce
+        .get()
+        .renderTemplate(SOY_TEMPLATE_NAMESPACE + templateName)
+        .setData(soyContext);
   }
 
   protected void removeUser(Account user) {
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java b/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
index bc6c89e..e28a77d 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
@@ -24,6 +24,13 @@
 import org.apache.commons.validator.routines.EmailValidator;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * Validator that checks if an email address is valid and allowed for receiving notification emails.
+ *
+ * <p>An email address is valid if it is syntactically correct.
+ *
+ * <p>An email address is allowed if its top level domain is allowed by Gerrit's configuration.
+ */
 @Singleton
 public class OutgoingEmailValidator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 934a0a0..be36f55 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -241,9 +241,9 @@
     Predicate<ChangeData> p = null;
 
     if (user == null) {
-      qb = args.queryBuilder.asUser(args.anonymousUser);
+      qb = args.queryBuilder.get().asUser(args.anonymousUser.get());
     } else {
-      qb = args.queryBuilder.asUser(user);
+      qb = args.queryBuilder.get().asUser(user);
       p = qb.is_visible();
     }
 
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index 91d8e81..e1daec6 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -24,6 +24,10 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+/**
+ * Sender that informs a user by email about the registration of a new email address for their
+ * account.
+ */
 public class RegisterNewEmailSender extends OutgoingEmail {
   public interface Factory {
     RegisterNewEmailSender create(String address);
@@ -58,17 +62,6 @@
     appendText(textTemplate("RegisterNewEmail"));
   }
 
-  public String getUserNameEmail() {
-    return getUserNameEmailFor(user.getAccountId());
-  }
-
-  public String getEmailRegistrationToken() {
-    if (emailToken == null) {
-      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
-    }
-    return emailToken;
-  }
-
   public boolean isAllowed() {
     return args.emailSender.canEmail(addr);
   }
@@ -77,6 +70,13 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("emailRegistrationToken", getEmailRegistrationToken());
-    soyContextEmailData.put("userNameEmail", getUserNameEmail());
+    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
+  }
+
+  private String getEmailRegistrationToken() {
+    if (emailToken == null) {
+      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+    }
+    return emailToken;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
index 850f775..2b1e362 100644
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
@@ -22,6 +22,13 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+/**
+ * Sender that informs a user by email that they were set as assignee on a change.
+ *
+ * <p>In contrast to other change emails this email is not sent to the change authors (owner, patch
+ * set uploader, author). This is why this class extends {@link ChangeEmail} directly, instead of
+ * extending {@link ReplyToChangeSender}.
+ */
 public class SetAssigneeSender extends ChangeEmail {
   public interface Factory {
     SetAssigneeSender create(Project.NameKey project, Change.Id changeId, Account.Id assignee);
@@ -54,14 +61,10 @@
     }
   }
 
-  public String getAssigneeName() {
-    return getNameFor(assignee);
-  }
-
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
-    soyContextEmailData.put("assigneeName", getAssigneeName());
+    soyContextEmailData.put("assigneeName", getNameFor(assignee));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 0acf20e..9b5b4d4 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -23,7 +22,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -76,6 +75,7 @@
     }
   }
 
+  /** An {@link AutoCloseable} for parsing a single commit into ChangeNotesCommits. */
   public static class LoadHandle implements AutoCloseable {
     private final Repository repo;
     private final ObjectId id;
@@ -140,7 +140,7 @@
     if (args.failOnLoadForTest.get()) {
       throw new StorageException("Reading from NoteDb is disabled");
     }
-    try (Timer1.Context<NoteDbTable> timer = args.metrics.readLatency.start(CHANGES);
+    try (Timer0.Context timer = args.metrics.readLatency.start();
         Repository repo = args.repoManager.openRepository(getProjectName());
         // Call openHandle even if reading is disabled, to trigger
         // auto-rebuilding before this object may get passed to a ChangeUpdate.
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index ce88f07..d301d34 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -51,11 +51,11 @@
   private final Change change;
   protected final PersonIdent serverIdent;
 
-  protected PatchSet.Id psId;
+  @Nullable protected PatchSet.Id psId;
   private ObjectId result;
-  protected boolean rootOnly;
+  boolean rootOnly;
 
-  protected AbstractChangeUpdate(
+  AbstractChangeUpdate(
       ChangeNotes notes,
       CurrentUser user,
       PersonIdent serverIdent,
@@ -72,7 +72,7 @@
     this.when = when;
   }
 
-  protected AbstractChangeUpdate(
+  AbstractChangeUpdate(
       ChangeNoteUtil noteUtil,
       PersonIdent serverIdent,
       @Nullable ChangeNotes notes,
@@ -172,7 +172,7 @@
   public abstract boolean isEmpty();
 
   /** Wether this update can only be a root commit. */
-  public boolean isRootOnly() {
+  boolean isRootOnly() {
     return rootOnly;
   }
 
@@ -256,7 +256,7 @@
   protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws IOException;
 
-  protected static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
+  static final CommitBuilder NO_OP_UPDATE = new CommitBuilder();
 
   ObjectId getResult() {
     return result;
@@ -270,7 +270,7 @@
     return ins.insert(Constants.OBJ_TREE, new byte[] {});
   }
 
-  protected void verifyComment(Comment c) {
+  void verifyComment(Comment c) {
     checkArgument(c.getCommitId() != null, "commit ID required for comment: %s", c);
     checkArgument(
         c.author.getId().equals(getAccountId()),
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
index 5d909d0..030cfb2 100644
--- a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -91,7 +91,7 @@
         executor.submit(
             () -> {
               try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
-                allUsersRepo.addUpdates(draftUpdates);
+                allUsersRepo.addUpdatesNoLimits(draftUpdates);
                 allUsersRepo.flush();
                 BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
                 bru.setPushCertificate(pushCert);
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index b55ce49..72a460c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -203,6 +203,7 @@
       cache.get(k.commitId()).deleteComment(k.key());
     }
 
+    // keyed by commit ID.
     Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
     for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
@@ -233,7 +234,8 @@
       return null;
     }
 
-    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    ObjectId treeId = rnm.noteMap.writeTree(ins);
+    cb.setTreeId(treeId);
     return cb;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index b221ef5..cebb67d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -48,6 +48,7 @@
   public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
   public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
   public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+  public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
 
   static final String AUTHOR = "Author";
   static final String BASE_PATCH_SET = "Base-for-patch-set";
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 78f6afc..71cb8c9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -43,6 +43,8 @@
  * </ul>
  */
 public class ChangeNotesCommit extends RevCommit {
+
+  /** A {@link RevWalk} producing {@link ChangeNotesCommit}s. */
   public static ChangeNotesRevWalk newRevWalk(Repository repo) {
     return new ChangeNotesRevWalk(repo);
   }
@@ -62,6 +64,7 @@
     };
   }
 
+  /** A {@link RevWalk} that creates {@link ChangeNotesCommit}s rather than {@link RevCommit}s */
   public static class ChangeNotesRevWalk extends RevWalk {
     private ChangeNotesRevWalk(Repository repo) {
       super(repo);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 60162cd..428df16 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
@@ -36,7 +37,6 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 
@@ -65,7 +65,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.mail.Address;
-import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -148,6 +148,7 @@
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
   private int updateCount;
+  private PatchSet.Id cherryPickOf;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -185,7 +186,7 @@
     walk.reset();
     walk.markStart(walk.parseCommit(tip));
 
-    try (Timer1.Context<NoteDbTable> timer = metrics.parseLatency.start(CHANGES)) {
+    try (Timer0.Context timer = metrics.parseLatency.start()) {
       ChangeNotesCommit commit;
       while ((commit = walk.next()) != null) {
         parse(commit);
@@ -246,6 +247,7 @@
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
         revertOf,
+        cherryPickOf,
         updateCount);
   }
 
@@ -417,6 +419,10 @@
       revertOf = parseRevertOf(commit);
     }
 
+    if (cherryPickOf == null) {
+      cherryPickOf = parseCherryPickOf(commit);
+    }
+
     previousWorkInProgressFooter = null;
     parseWorkInProgress(commit);
   }
@@ -968,6 +974,18 @@
     return Change.id(revertOf);
   }
 
+  private PatchSet.Id parseCherryPickOf(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String cherryPickOf = parseOneFooter(commit, FOOTER_CHERRY_PICK_OF);
+    if (cherryPickOf == null) {
+      return null;
+    }
+    try {
+      return PatchSet.Id.parse(cherryPickOf);
+    } catch (IllegalArgumentException e) {
+      throw new ConfigInvalidException("\"" + cherryPickOf + "\" is not a valid patchset", e);
+    }
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 896cca3..064e43b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -123,6 +123,7 @@
       boolean workInProgress,
       boolean reviewStarted,
       @Nullable Change.Id revertOf,
+      @Nullable PatchSet.Id cherryPickOf,
       int updateCount) {
     requireNonNull(
         metaId,
@@ -152,6 +153,7 @@
                 .workInProgress(workInProgress)
                 .reviewStarted(reviewStarted)
                 .revertOf(revertOf)
+                .cherryPickOf(cherryPickOf)
                 .build())
         .hashtags(hashtags)
         .serverId(serverId)
@@ -220,6 +222,9 @@
     @Nullable
     abstract Change.Id revertOf();
 
+    @Nullable
+    abstract PatchSet.Id cherryPickOf();
+
     abstract Builder toBuilder();
 
     @AutoValue.Builder
@@ -254,6 +259,8 @@
 
       abstract Builder revertOf(@Nullable Change.Id revertOf);
 
+      abstract Builder cherryPickOf(@Nullable PatchSet.Id cherryPickOf);
+
       abstract ChangeColumns build();
     }
   }
@@ -341,6 +348,7 @@
     change.setWorkInProgress(c.workInProgress());
     change.setReviewStarted(c.reviewStarted());
     change.setRevertOf(c.revertOf());
+    change.setCherryPickOf(c.cherryPickOf());
 
     if (!patchSets().isEmpty()) {
       change.setCurrentPatchSet(c.currentPatchSetId(), c.subject(), c.originalSubject());
@@ -514,6 +522,10 @@
       if (cols.revertOf() != null) {
         b.setRevertOf(cols.revertOf().get()).setHasRevertOf(true);
       }
+      if (cols.cherryPickOf() != null) {
+        b.setCherryPickOf(cols.cherryPickOf().getCommaSeparatedChangeAndPatchSetId())
+            .setHasCherryPickOf(true);
+      }
       return b.build();
     }
 
@@ -637,6 +649,9 @@
       if (proto.getHasRevertOf()) {
         b.revertOf(Change.id(proto.getRevertOf()));
       }
+      if (proto.getHasCherryPickOf()) {
+        b.cherryPickOf(PatchSet.Id.parse(proto.getCherryPickOf()));
+      }
       return b.build();
     }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index b6443f1..4e52093 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -28,6 +28,7 @@
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
 
+/** Implements the parsing of comment data, handling JSON decoding and push certificates. */
 class ChangeRevisionNote extends RevisionNote<Comment> {
   private final ChangeNoteJson noteJson;
   private final Comment.Status status;
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index b2f85fc..2822f61 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
@@ -55,11 +56,11 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.assistedinject.Assisted;
@@ -137,6 +138,7 @@
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Integer revertOf;
+  private String cherryPickOf;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
@@ -220,8 +222,10 @@
     this.status = status;
   }
 
-  public void fixStatus(Change.Status status) {
-    this.status = status;
+  public void fixStatusToMerged(SubmissionId submissionId) {
+    checkArgument(submissionId != null, "submission id must be set for merged changes");
+    this.status = Change.Status.MERGED;
+    this.submissionId = submissionId.toString();
   }
 
   public void putApproval(String label, short value) {
@@ -240,9 +244,9 @@
     approvals.put(label, reviewer, Optional.empty());
   }
 
-  public void merge(RequestId submissionId, Iterable<SubmitRecord> submitRecords) {
+  public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
-    this.submissionId = submissionId.toStringForStorage();
+    this.submissionId = submissionId.toString();
     this.submitRecords = ImmutableList.copyOf(submitRecords);
     checkArgument(!this.submitRecords.isEmpty(), "no submit records specified at submit time");
   }
@@ -415,6 +419,10 @@
     rootOnly = true;
   }
 
+  public void setCherryPickOf(String cherryPickOf) {
+    this.cherryPickOf = cherryPickOf;
+  }
+
   /** @return the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
@@ -663,6 +671,10 @@
       addFooter(msg, FOOTER_REVERT_OF, revertOf);
     }
 
+    if (cherryPickOf != null) {
+      addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf);
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -712,7 +724,8 @@
         && !currentPatchSet
         && isPrivate == null
         && workInProgress == null
-        && revertOf == null;
+        && revertOf == null
+        && cherryPickOf == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
diff --git a/java/com/google/gerrit/server/notedb/IntBlob.java b/java/com/google/gerrit/server/notedb/IntBlob.java
index c0af37f..61b9ae0 100644
--- a/java/com/google/gerrit/server/notedb/IntBlob.java
+++ b/java/com/google/gerrit/server/notedb/IntBlob.java
@@ -39,6 +39,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/** An object blob in a Git repository that stores a single integer value. */
 @AutoValue
 public abstract class IntBlob {
   public static Optional<IntBlob> parse(Repository repo, String refName) throws IOException {
diff --git a/java/com/google/gerrit/server/notedb/LimitExceededException.java b/java/com/google/gerrit/server/notedb/LimitExceededException.java
new file mode 100644
index 0000000..0a57d96
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/LimitExceededException.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.exceptions.StorageException;
+
+/**
+ * A write operation was rejected because a limit would be exceeded. Limits are currently imposed
+ * on:
+ *
+ * <ul>
+ *   <li>The number of NoteDb updates per change.
+ *   <li>The number of patch sets per change.
+ *   <li>The number of files per change.
+ * </ul>
+ */
+public class LimitExceededException extends StorageException {
+  private static final long serialVersionUID = 1L;
+
+  public LimitExceededException(String message) {
+    super(message);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
index 18ffd17..246e7e1 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbMetrics.java
@@ -16,69 +16,61 @@
 
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.metrics.Timer0;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/** Metrics for accessing and updating changes in NoteDb. */
 @Singleton
 class NoteDbMetrics {
   /** End-to-end latency for writing a collection of updates. */
-  final Timer1<NoteDbTable> updateLatency;
+  final Timer0 updateLatency;
 
   /**
    * The portion of {@link #updateLatency} due to preparing the sequence of updates.
    *
    * <p>May include some I/O (e.g. reading old refs), but excludes writes.
    */
-  final Timer1<NoteDbTable> stageUpdateLatency;
+  final Timer0 stageUpdateLatency;
 
   /** End-to-end latency for reading changes from NoteDb, including reading ref(s) and parsing. */
-  final Timer1<NoteDbTable> readLatency;
+  final Timer0 readLatency;
 
   /**
    * The portion of {@link #readLatency} due to parsing commits, but excluding I/O (to a best
    * effort).
    */
-  final Timer1<NoteDbTable> parseLatency;
+  final Timer0 parseLatency;
 
   @Inject
   NoteDbMetrics(MetricMaker metrics) {
-    Field<NoteDbTable> tableField =
-        Field.ofEnum(NoteDbTable.class, "table", Metadata.Builder::noteDbTable).build();
-
     updateLatency =
         metrics.newTimer(
             "notedb/update_latency",
-            new Description("NoteDb update latency by table")
+            new Description("NoteDb update latency for changes")
                 .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            tableField);
+                .setUnit(Units.MILLISECONDS));
 
     stageUpdateLatency =
         metrics.newTimer(
             "notedb/stage_update_latency",
-            new Description("Latency for staging updates to NoteDb by table")
+            new Description("Latency for staging change updates to NoteDb")
                 .setCumulative()
-                .setUnit(Units.MICROSECONDS),
-            tableField);
+                .setUnit(Units.MICROSECONDS));
 
     readLatency =
         metrics.newTimer(
             "notedb/read_latency",
-            new Description("NoteDb read latency by table")
+            new Description("NoteDb read latency for changes")
                 .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            tableField);
+                .setUnit(Units.MILLISECONDS));
 
     parseLatency =
         metrics.newTimer(
             "notedb/parse_latency",
-            new Description("NoteDb parse latency by table")
+            new Description("NoteDb parse latency for changes")
                 .setCumulative()
-                .setUnit(Units.MICROSECONDS),
-            tableField);
+                .setUnit(Units.MICROSECONDS));
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 7022cdc..2d1a04a 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -27,7 +26,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -64,6 +63,10 @@
  * {@link #stage()}.
  */
 public class NoteDbUpdateManager implements AutoCloseable {
+  private static final int MAX_UPDATES_DEFAULT = 1000;
+  /** Limits the number of patch sets that can be created. Can be overridden in the config. */
+  private static final int MAX_PATCH_SETS_DEFAULT = 1500;
+
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
@@ -74,11 +77,12 @@
   private final NoteDbMetrics metrics;
   private final Project.NameKey projectName;
   private final int maxUpdates;
+  private final int maxPatchSets;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
   private final ListMultimap<String, NoteDbRewriter> rewriters;
-  private final Set<Change.Id> toDelete;
+  private final Set<Change.Id> changesToDelete;
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
@@ -103,12 +107,13 @@
     this.metrics = metrics;
     this.updateAllUsersAsync = updateAllUsersAsync;
     this.projectName = projectName;
-    maxUpdates = cfg.getInt("change", null, "maxUpdates", 1000);
+    maxUpdates = cfg.getInt("change", null, "maxUpdates", MAX_UPDATES_DEFAULT);
+    maxPatchSets = cfg.getInt("change", null, "maxPatchSets", MAX_PATCH_SETS_DEFAULT);
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     rewriters = MultimapBuilder.hashKeys().arrayListValues().build();
-    toDelete = new HashSet<>();
+    changesToDelete = new HashSet<>();
   }
 
   @Override
@@ -181,7 +186,7 @@
         && draftUpdates.isEmpty()
         && robotCommentUpdates.isEmpty()
         && rewriters.isEmpty()
-        && toDelete.isEmpty()
+        && changesToDelete.isEmpty()
         && !hasCommands(changeRepo)
         && !hasCommands(allUsersRepo)
         && updateAllUsersAsync.isEmpty();
@@ -258,7 +263,7 @@
 
   public void deleteChange(Change.Id id) {
     checkNotExecuted();
-    toDelete.add(id);
+    changesToDelete.add(id);
   }
 
   /**
@@ -267,13 +272,13 @@
    * @throws IOException if a storage layer error occurs.
    */
   private void stage() throws IOException {
-    try (Timer1.Context<NoteDbTable> timer = metrics.stageUpdateLatency.start(CHANGES)) {
+    try (Timer0.Context timer = metrics.stageUpdateLatency.start()) {
       if (isEmpty()) {
         return;
       }
 
       initChangeRepo();
-      if (!draftUpdates.isEmpty() || !toDelete.isEmpty()) {
+      if (!draftUpdates.isEmpty() || !changesToDelete.isEmpty()) {
         initAllUsersRepo();
       }
       addCommands();
@@ -292,7 +297,7 @@
       executed = true;
       return null;
     }
-    try (Timer1.Context<NoteDbTable> timer = metrics.updateLatency.start(CHANGES)) {
+    try (Timer0.Context timer = metrics.updateLatency.start()) {
       stage();
       // ChangeUpdates must execute before ChangeDraftUpdates.
       //
@@ -351,23 +356,23 @@
   }
 
   private void addCommands() throws IOException {
-    changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates));
+    changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates), Optional.of(maxPatchSets));
     if (!draftUpdates.isEmpty()) {
       boolean publishOnly = draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
       if (publishOnly) {
         updateAllUsersAsync.setDraftUpdates(draftUpdates);
       } else {
-        allUsersRepo.addUpdates(draftUpdates);
+        allUsersRepo.addUpdatesNoLimits(draftUpdates);
       }
     }
     if (!robotCommentUpdates.isEmpty()) {
-      changeRepo.addUpdates(robotCommentUpdates);
+      changeRepo.addUpdatesNoLimits(robotCommentUpdates);
     }
     if (!rewriters.isEmpty()) {
       addRewrites(rewriters, changeRepo);
     }
 
-    for (Change.Id id : toDelete) {
+    for (Change.Id id : changesToDelete) {
       doDelete(id);
     }
   }
@@ -375,17 +380,16 @@
   private void doDelete(Change.Id id) throws IOException {
     String metaRef = RefNames.changeMetaRef(id);
     Optional<ObjectId> old = changeRepo.cmds.get(metaRef);
-    if (old.isPresent()) {
-      changeRepo.cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), metaRef));
-    }
+    old.ifPresent(
+        objectId -> changeRepo.cmds.add(new ReceiveCommand(objectId, ObjectId.zeroId(), metaRef)));
 
     // Just scan repo for ref names, but get "old" values from cmds.
     for (Ref r :
         allUsersRepo.repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(id))) {
       old = allUsersRepo.cmds.get(r.getName());
-      if (old.isPresent()) {
-        allUsersRepo.cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), r.getName()));
-      }
+      old.ifPresent(
+          objectId ->
+              allUsersRepo.cmds.add(new ReceiveCommand(objectId, ObjectId.zeroId(), r.getName())));
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 58a33c8..396e29b 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -20,7 +20,6 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import java.sql.Timestamp;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -34,8 +33,7 @@
   private static final ImmutableList<String> PACKAGE_PREFIXES =
       ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
   private static final ImmutableSet<String> SERVLET_NAMES =
-      ImmutableSet.of(
-          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
+      ImmutableSet.of("com.google.gerrit.httpd.restapi.RestApiServlet");
 
   /** Returns an AccountId for the given email address. */
   public static Optional<Account.Id> parseIdent(PersonIdent ident) {
@@ -74,13 +72,16 @@
     StackTraceElement[] trace = Thread.currentThread().getStackTrace();
     int i = findRestApiServlet(trace);
     if (i < 0) {
+      i = findApiImpl(trace);
+    }
+    if (i < 0) {
       return null;
     }
     try {
       for (i--; i >= 0; i--) {
         String cn = trace[i].getClassName();
         Class<?> cls = Class.forName(cn);
-        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
+        if (RestModifyView.class.isAssignableFrom(cls)) {
           return viewName(cn);
         }
       }
@@ -110,6 +111,16 @@
     return -1;
   }
 
+  private static int findApiImpl(StackTraceElement[] trace) {
+    for (int i = 0; i < trace.length; i++) {
+      String clazz = trace[i].getClassName();
+      if (clazz.startsWith("com.google.gerrit.server.api.") && clazz.endsWith("ApiImpl")) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
   private static String viewName(String cn) {
     String impl = cn.replace('$', '.');
     for (String p : PACKAGE_PREFIXES) {
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
index de88684..351f31d 100644
--- a/java/com/google/gerrit/server/notedb/OpenRepo.java
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -43,6 +43,15 @@
  * objects that are jointly closed when invoking {@link #close}.
  */
 class OpenRepo implements AutoCloseable {
+  final Repository repo;
+  final RevWalk rw;
+  final ChainedReceiveCommands cmds;
+  final ObjectInserter tempIns;
+
+  private final InMemoryInserter inMemIns;
+  @Nullable private final ObjectInserter finalIns;
+  private final boolean close;
+
   /** Returns a {@link OpenRepo} wrapping around an open {@link Repository}. */
   static OpenRepo open(GitRepositoryManager repoManager, Project.NameKey project)
       throws IOException {
@@ -60,15 +69,6 @@
     }
   }
 
-  final Repository repo;
-  final RevWalk rw;
-  final ChainedReceiveCommands cmds;
-  final ObjectInserter tempIns;
-
-  private final InMemoryInserter inMemIns;
-  @Nullable private final ObjectInserter finalIns;
-  private final boolean close;
-
   OpenRepo(
       Repository repo,
       RevWalk rw,
@@ -125,12 +125,15 @@
     return updates.iterator().next().allowWriteToNewRef();
   }
 
-  <U extends AbstractChangeUpdate> void addUpdates(ListMultimap<String, U> all) throws IOException {
-    addUpdates(all, Optional.empty());
+  <U extends AbstractChangeUpdate> void addUpdatesNoLimits(ListMultimap<String, U> all)
+      throws IOException {
+    addUpdates(
+        all, Optional.empty() /* unlimited updates */, Optional.empty() /* unlimited patch sets */);
   }
 
   <U extends AbstractChangeUpdate> void addUpdates(
-      ListMultimap<String, U> all, Optional<Integer> maxUpdates) throws IOException {
+      ListMultimap<String, U> all, Optional<Integer> maxUpdates, Optional<Integer> maxPatchSets)
+      throws IOException {
     for (Map.Entry<String, Collection<U>> e : all.asMap().entrySet()) {
       String refName = e.getKey();
       Collection<U> updates = e.getValue();
@@ -142,29 +145,43 @@
         continue;
       }
 
-      int updateCount;
+      int updateCount = 0;
       U first = updates.iterator().next();
       if (maxUpdates.isPresent()) {
         checkState(first.getNotes() != null, "expected ChangeNotes on %s", first);
         updateCount = first.getNotes().getUpdateCount();
-      } else {
-        updateCount = 0;
       }
 
       ObjectId curr = old;
-      for (U u : updates) {
-        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
+      for (U update : updates) {
+        if (maxPatchSets.isPresent() && update.psId != null) {
+          // Patch set IDs are assigned consecutively. Patch sets may have been deleted, but the ID
+          // is still a good estimate and an upper bound.
+          if (update.psId.get() > maxPatchSets.get()) {
+            throw new LimitExceededException(
+                String.format(
+                    "Change %d may not exceed %d patch sets. To continue working on this change, "
+                        + "recreate it with a new Change-Id, then abandon this one.",
+                    update.getId().get(), maxPatchSets.get()));
+          }
+        }
+        if (update.isRootOnly() && !old.equals(ObjectId.zeroId())) {
           throw new StorageException("Given ChangeUpdate is only allowed on initial commit");
         }
-        ObjectId next = u.apply(rw, tempIns, curr);
+        ObjectId next = update.apply(rw, tempIns, curr);
         if (next == null) {
           continue;
         }
         if (maxUpdates.isPresent()
             && !Objects.equals(next, curr)
             && ++updateCount > maxUpdates.get()
-            && !u.bypassMaxUpdates()) {
-          throw new TooManyUpdatesException(u.getId(), maxUpdates.get());
+            && !update.bypassMaxUpdates()) {
+          throw new LimitExceededException(
+              String.format(
+                  "Change %s may not exceed %d updates. It may still be abandoned or submitted. To"
+                      + " continue working on this change, recreate it with a new Change-Id, then"
+                      + " abandon this one.",
+                  update.getId(), maxUpdates.get()));
         }
         curr = next;
       }
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 8096f89..11ba8cd 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -291,4 +291,48 @@
     ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
     return new ReceiveCommand(ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + name);
   }
+
+  public void storeNew(int value) {
+    counterLock.lock();
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
+      afterReadRef.run();
+      ObjectId oldId;
+      if (!blob.isPresent()) {
+        oldId = ObjectId.zeroId();
+      } else {
+        oldId = blob.get().id();
+      }
+      RefUpdate refUpdate =
+          IntBlob.tryStore(repo, rw, projectName, refName, oldId, value, gitRefUpdated);
+      RefUpdateUtil.checkResult(refUpdate);
+      counter = value;
+      limit = counter + batchSize;
+      acquireCount++;
+    } catch (IOException e) {
+      throw new StorageException(e);
+    } finally {
+      counterLock.unlock();
+    }
+  }
+
+  public int current() {
+    counterLock.lock();
+    try (Repository repo = repoManager.openRepository(projectName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
+      int current;
+      if (!blob.isPresent()) {
+        current = seed.get();
+      } else {
+        current = blob.get().value();
+      }
+      return current;
+    } catch (IOException e) {
+      throw new StorageException(e);
+    } finally {
+      counterLock.unlock();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNote.java b/java/com/google/gerrit/server/notedb/RevisionNote.java
index ff649a9..cd11e1b 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -26,6 +26,10 @@
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
 
+/**
+ * Data stored in a note, parsed on demand. The data type to parse into is a generic list of type T.
+ * The source of the data is a array of raw bytes
+ */
 @UsedAt(UsedAt.Project.PLUGIN_CHECKS)
 public abstract class RevisionNote<T> {
   static final int MAX_NOTE_SZ = 25 << 20;
@@ -64,6 +68,7 @@
     return entities;
   }
 
+  /** Reads the raw data, and delegates parsing to the {@link #parse(byte[], int)} method. */
   public void parse() throws IOException, ConfigInvalidException {
     raw = reader.open(noteId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     MutableInteger p = new MutableInteger();
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index e63737c..81273dc 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -36,6 +36,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 class RevisionNoteBuilder {
+  /** Construct a new RevisionNoteMap, seeding it with an existing (immutable) RevisionNoteMap */
   static class Cache {
     private final RevisionNoteMap<? extends RevisionNote<? extends Comment>> revisionNoteMap;
     private final Map<ObjectId, RevisionNoteBuilder> builders;
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 3e1bad1..cf16073 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -25,8 +25,16 @@
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 
+/**
+ * A utility class that parses a NoteMap into commit => comment list data.
+ *
+ * @param <T> the RevisionNote for the comment type.
+ */
 class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
+  // CommitID => blob ID
   final NoteMap noteMap;
+
+  // CommitID => parsed data, immutable map.
   final ImmutableMap<ObjectId, T> revisionNotes;
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
@@ -36,6 +44,7 @@
     for (Note note : noteMap) {
       ChangeRevisionNote rn = new ChangeRevisionNote(noteJson, reader, note.getData(), status);
       rn.parse();
+
       result.put(note.copy(), rn);
     }
     return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
index 97a8ad4..fc4c9fd 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -26,6 +26,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 
+/** Like {@link RevisionNote} but for robot comments. */
 public class RobotCommentsRevisionNote extends RevisionNote<RobotComment> {
   private final ChangeNoteJson noteUtil;
 
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
index 73cc600..be68592 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -128,4 +128,28 @@
       return groupSeq.next();
     }
   }
+
+  public int currentChangeId() {
+    return changeSeq.current();
+  }
+
+  public int currentAccountId() {
+    return accountSeq.current();
+  }
+
+  public int currentGroupId() {
+    return groupSeq.current();
+  }
+
+  public void setChangeIdValue(int value) {
+    changeSeq.storeNew(value);
+  }
+
+  public void setAccountIdValue(int value) {
+    accountSeq.storeNew(value);
+  }
+
+  public void setGroupIdValue(int value) {
+    groupSeq.storeNew(value);
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java b/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
deleted file mode 100644
index 9c6faaf..0000000
--- a/java/com/google/gerrit/server/notedb/TooManyUpdatesException.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-
-/**
- * Exception indicating that the change has received too many updates. Further actions apart from
- * {@code abandon} or {@code submit} are blocked.
- */
-public class TooManyUpdatesException extends StorageException {
-  @VisibleForTesting
-  public static String message(Change.Id id, int maxUpdates) {
-    return "Change "
-        + id
-        + " may not exceed "
-        + maxUpdates
-        + " updates. It may still be abandoned or submitted. To continue working on this "
-        + "change, recreate it with a new Change-Id, then abandon this one.";
-  }
-
-  private static final long serialVersionUID = 1L;
-
-  TooManyUpdatesException(Change.Id id, int maxUpdates) {
-    super(message(id, maxUpdates));
-  }
-}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index a9cc9b5..2e0214c 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -16,14 +16,17 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.flogger.FluentLogger;
+import com.google.common.base.Throwables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.eclipse.jgit.dircache.DirCache;
@@ -42,28 +45,54 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Utility class for creating an auto-merge commit of a merge commit.
+ *
+ * <p>An auto-merge commit is the result of merging the 2 parents of a merge commit automatically.
+ * If there are conflicts the auto-merge commit contains Git conflict markers that indicate these
+ * conflicts.
+ *
+ * <p>Creating auto-merge commits for octopus merges (merge commits with more than 2 parents) is not
+ * supported. In this case the auto-merge is created between the first 2 parent commits.
+ *
+ * <p>All created auto-merge commits are stored in the repository of their merge commit as {@code
+ * refs/cache-automerge/} branches. These branches serve:
+ *
+ * <ul>
+ *   <li>as a cache so that the each auto-merge gets computed only once
+ *   <li>as base for merge commits on which users can comment
+ * </ul>
+ *
+ * <p>The second point means that these commits are referenced from NoteDb. The consequence of this
+ * is that these refs should never be deleted.
+ */
 public class AutoMerger {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
+  private final RetryHelper retryHelper;
   private final PersonIdent gerritIdent;
   private final boolean save;
 
   @Inject
-  AutoMerger(@GerritServerConfig Config cfg, @GerritPersonIdent PersonIdent gerritIdent) {
+  AutoMerger(
+      RetryHelper retryHelper,
+      @GerritServerConfig Config cfg,
+      @GerritPersonIdent PersonIdent gerritIdent) {
+    this.retryHelper = retryHelper;
     save = cacheAutomerge(cfg);
     this.gerritIdent = gerritIdent;
   }
 
   /**
-   * Perform an auto-merge of the parents of the given merge commit.
+   * Creates an auto-merge commit of the parents of the given merge commit.
    *
-   * @return auto-merge commit or {@code null} if an auto-merge commit couldn't be created. Headers
-   *     of the returned RevCommit are parsed.
+   * <p>In case of an exception the creation of the auto-merge commit is retried a few times. E.g.
+   * this allows the operation to succeed if a Git update fails due to a temporary issue.
+   *
+   * @return auto-merge commit. Headers of the returned RevCommit are parsed.
    */
   public RevCommit merge(
       Repository repo,
@@ -72,7 +101,34 @@
       RevCommit merge,
       ThreeWayMergeStrategy mergeStrategy)
       throws IOException {
+    try {
+      return retryHelper
+          .action(
+              ActionType.GIT_UPDATE,
+              "createAutoMerge",
+              () -> createAutoMergeCommit(repo, rw, ins, merge, mergeStrategy))
+          .call();
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /**
+   * Creates an auto-merge commit of the parents of the given merge commit.
+   *
+   * @return auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  private RevCommit createAutoMergeCommit(
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter ins,
+      RevCommit merge,
+      ThreeWayMergeStrategy mergeStrategy)
+      throws IOException {
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+
     InMemoryInserter tmpIns = null;
     if (ins instanceof InMemoryInserter) {
       // Caller gave us an in-memory inserter, so ensure anything we write from
@@ -102,17 +158,7 @@
     m.setDirCache(dc);
     m.setObjectInserter(tmpIns == null ? new NonFlushingWrapper(ins) : tmpIns);
 
-    boolean couldMerge;
-    try {
-      couldMerge = m.merge(merge.getParents());
-    } catch (IOException | RuntimeException e) {
-      // It is not safe to continue further down in this method as throwing
-      // an exception most likely means that the merge tree was not created
-      // and m.getMergeResults() is empty. This would mean that all paths are
-      // unmerged and Gerrit UI would show all paths in the patch list.
-      logger.atWarning().withCause(e).log("Error attempting automerge %s", refName);
-      return null;
-    }
+    boolean couldMerge = m.merge(merge.getParents());
 
     ObjectId treeId;
     if (couldMerge) {
@@ -173,8 +219,28 @@
     RefUpdate ru = repo.updateRef(refName);
     ru.setNewObjectId(commitId);
     ru.disableRefLog();
-    ru.forceUpdate();
-    return rw.parseCommit(commitId);
+    switch (ru.forceUpdate()) {
+      case FAST_FORWARD:
+      case FORCED:
+      case NEW:
+      case NO_CHANGE:
+        return rw.parseCommit(commitId);
+      case LOCK_FAILURE:
+        throw new LockFailureException(
+            String.format("Failed to create auto-merge of %s", merge.name()), ru);
+      case IO_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      case RENAMED:
+      default:
+        throw new IOException(
+            String.format(
+                "Failed to create auto-merge of %s: Cannot write %s (%s)",
+                merge.name(), refName, ru.getResult()));
+    }
   }
 
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
diff --git a/java/com/google/gerrit/server/patch/DiffContentCalculator.java b/java/com/google/gerrit/server/patch/DiffContentCalculator.java
new file mode 100644
index 0000000..53f7ca6
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffContentCalculator.java
@@ -0,0 +1,417 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static java.util.Comparator.comparing;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.CommentDetail;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.jgit.diff.ReplaceEdit;
+import com.google.gerrit.prettify.common.EditList;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.prettify.common.SparseFileContentBuilder;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.diff.Edit;
+
+/** Collects all lines and their content to be displayed in diff view. */
+class DiffContentCalculator {
+  private static final int MAX_CONTEXT = 5000000;
+
+  private static final Comparator<Edit> EDIT_SORT = comparing(Edit::getBeginA);
+
+  private final DiffPreferencesInfo diffPrefs;
+
+  DiffContentCalculator(DiffPreferencesInfo diffPrefs) {
+    this.diffPrefs = diffPrefs;
+  }
+
+  /**
+   * Gather information necessary to display line-by-line difference between 2 texts.
+   *
+   * <p>The method returns instance of {@link DiffCalculatorResult} with the following data:
+   *
+   * <ul>
+   *   <li>All changed lines
+   *   <li>Additional lines to be displayed above and below the changed lines
+   *   <li>All changed and unchanged lines with comments
+   *   <li>Additional lines to be displayed above and below lines with commentsEdits with special
+   *       "fake" edits for unchanged lines with comments
+   * </ul>
+   *
+   * <p>More details can be found in {@link DiffCalculatorResult}.
+   *
+   * @param srcA Original text content
+   * @param srcB New text content
+   * @param edits List of edits which was applied to srcA to produce srcB
+   * @param comments Existing comments for srcA and srcB
+   * @return an instance of {@link DiffCalculatorResult}.
+   */
+  DiffCalculatorResult calculateDiffContent(
+      TextSource srcA, TextSource srcB, ImmutableList<Edit> edits, CommentDetail comments) {
+    int context = getContext();
+    if (srcA.src == srcB.src && srcA.size() <= context && edits.isEmpty()) {
+      // Odd special case; the files are identical (100% rename or copy)
+      // and the user has asked for context that is larger than the file.
+      // Send them the entire file, with an empty edit after the last line.
+      //
+      SparseFileContentBuilder diffA = new SparseFileContentBuilder(srcA.size());
+      for (int i = 0; i < srcA.size(); i++) {
+        srcA.copyLineTo(diffA, i);
+      }
+      DiffContent diffContent =
+          new DiffContent(diffA.build(), SparseFileContent.create(ImmutableList.of(), srcB.size()));
+      Edit emptyEdit = new Edit(srcA.size(), srcA.size());
+      return new DiffCalculatorResult(diffContent, ImmutableList.of(emptyEdit));
+    }
+    ImmutableList.Builder<Edit> builder = ImmutableList.builder();
+
+    builder.addAll(correctForDifferencesInNewlineAtEnd(srcA, srcB, edits));
+
+    boolean nonsortedEdits = false;
+    if (comments != null) {
+      ImmutableList<Edit> commentEdits = ensureCommentsVisible(comments, edits);
+      builder.addAll(commentEdits);
+      nonsortedEdits = !commentEdits.isEmpty();
+    }
+
+    ImmutableList<Edit> sortedEdits = builder.build();
+    if (nonsortedEdits) {
+      sortedEdits = ImmutableList.sortedCopyOf(EDIT_SORT, sortedEdits);
+    }
+
+    // In order to expand the skipped common lines or syntax highlight the
+    // file properly we need to give the client the complete file contents.
+    // So force our context temporarily to the complete file size.
+    //
+    DiffContent diffContent =
+        packContent(
+            srcA,
+            srcB,
+            diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE,
+            sortedEdits,
+            MAX_CONTEXT);
+    return new DiffCalculatorResult(diffContent, sortedEdits);
+  }
+
+  private int getContext() {
+    if (diffPrefs.context == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
+      return MAX_CONTEXT;
+    }
+    return Math.min(diffPrefs.context, MAX_CONTEXT);
+  }
+
+  private ImmutableList<Edit> correctForDifferencesInNewlineAtEnd(
+      TextSource a, TextSource b, ImmutableList<Edit> edits) {
+    // a.src.size() is the size ignoring a newline at the end whereas a.size() considers it.
+    int aSize = a.src.size();
+    int bSize = b.src.size();
+
+    if (edits.isEmpty() && (aSize == 0 || bSize == 0)) {
+      // The diff was requested for a file which was either added or deleted but which JGit doesn't
+      // consider a file addition/deletion (e.g. requesting a diff for the old file name of a
+      // renamed file looks like a deletion).
+      return edits;
+    }
+
+    if (edits.isEmpty() && (aSize != bSize)) {
+      // Only edits due to rebase were present. If we now added the edits for the newlines, the
+      // code which later assembles the file contents would fail.
+      return edits;
+    }
+
+    Optional<Edit> lastEdit = getLast(edits);
+    if (isNewlineAtEndDeleted(a, b)) {
+      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
+
+      if (lastLineEdit.isPresent()) {
+        Edit edit = lastLineEdit.get();
+        Edit updatedLastLineEdit =
+            edit instanceof ReplaceEdit
+                ? new ReplaceEdit(
+                    edit.getBeginA(),
+                    edit.getEndA() + 1,
+                    edit.getBeginB(),
+                    edit.getEndB(),
+                    ((ReplaceEdit) edit).getInternalEdits())
+                : new Edit(edit.getBeginA(), edit.getEndA() + 1, edit.getBeginB(), edit.getEndB());
+
+        ImmutableList.Builder<Edit> newEditsBuilder =
+            ImmutableList.builderWithExpectedSize(edits.size());
+        return newEditsBuilder
+            .addAll(edits.subList(0, edits.size() - 1))
+            .add(updatedLastLineEdit)
+            .build();
+      }
+      ImmutableList.Builder<Edit> newEditsBuilder =
+          ImmutableList.builderWithExpectedSize(edits.size() + 1);
+      Edit newlineEdit = new Edit(aSize, aSize + 1, bSize, bSize);
+      return newEditsBuilder.addAll(edits).add(newlineEdit).build();
+
+    } else if (isNewlineAtEndAdded(a, b)) {
+      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndB() == bSize);
+      if (lastLineEdit.isPresent()) {
+        Edit edit = lastLineEdit.get();
+        Edit updatedLastLineEdit =
+            edit instanceof ReplaceEdit
+                ? new ReplaceEdit(
+                    edit.getBeginA(),
+                    edit.getEndA(),
+                    edit.getBeginB(),
+                    edit.getEndB() + 1,
+                    ((ReplaceEdit) edit).getInternalEdits())
+                : new Edit(edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB() + 1);
+
+        ImmutableList.Builder<Edit> newEditsBuilder =
+            ImmutableList.builderWithExpectedSize(edits.size());
+        return newEditsBuilder
+            .addAll(edits.subList(0, edits.size() - 1))
+            .add(updatedLastLineEdit)
+            .build();
+      }
+      ImmutableList.Builder<Edit> newEditsBuilder =
+          ImmutableList.builderWithExpectedSize(edits.size() + 1);
+      Edit newlineEdit = new Edit(aSize, aSize, bSize, bSize + 1);
+      return newEditsBuilder.addAll(edits).add(newlineEdit).build();
+    }
+    return edits;
+  }
+
+  private static <T> Optional<T> getLast(List<T> list) {
+    return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(list.size() - 1));
+  }
+
+  private boolean isNewlineAtEndDeleted(TextSource a, TextSource b) {
+    return !a.src.isMissingNewlineAtEnd() && b.src.isMissingNewlineAtEnd();
+  }
+
+  private boolean isNewlineAtEndAdded(TextSource a, TextSource b) {
+    return a.src.isMissingNewlineAtEnd() && !b.src.isMissingNewlineAtEnd();
+  }
+
+  private ImmutableList<Edit> ensureCommentsVisible(
+      CommentDetail comments, ImmutableList<Edit> edits) {
+    if (comments.getCommentsA().isEmpty() && comments.getCommentsB().isEmpty()) {
+      // No comments, no additional dummy edits are required.
+      //
+      return ImmutableList.of();
+    }
+
+    // Construct empty Edit blocks around each location where a comment is.
+    // This will force the later packContent method to include the regions
+    // containing comments, potentially combining those regions together if
+    // they have overlapping contexts. UI renders will also be able to make
+    // correct hunks from this, but because the Edit is empty they will not
+    // style it specially.
+    //
+    final ImmutableList.Builder<Edit> commmentEdits = ImmutableList.builder();
+    int lastLine;
+
+    lastLine = -1;
+    for (Comment c : comments.getCommentsA()) {
+      final int a = c.lineNbr;
+      if (lastLine != a) {
+        final int b = mapA2B(a - 1, edits);
+        if (0 <= b) {
+          getNewEditForComment(edits, new Edit(a - 1, b)).ifPresent(commmentEdits::add);
+        }
+        lastLine = a;
+      }
+    }
+
+    lastLine = -1;
+    for (Comment c : comments.getCommentsB()) {
+      int b = c.lineNbr;
+      if (lastLine != b) {
+        final int a = mapB2A(b - 1, edits);
+        if (0 <= a) {
+          getNewEditForComment(edits, new Edit(a, b - 1)).ifPresent(commmentEdits::add);
+        }
+        lastLine = b;
+      }
+    }
+    return commmentEdits.build();
+  }
+
+  private Optional<Edit> getNewEditForComment(ImmutableList<Edit> edits, Edit toAdd) {
+    final int a = toAdd.getBeginA();
+    final int b = toAdd.getBeginB();
+    for (Edit e : edits) {
+      if (e.getBeginA() <= a && a <= e.getEndA()) {
+        return Optional.empty();
+      }
+      if (e.getBeginB() <= b && b <= e.getEndB()) {
+        return Optional.empty();
+      }
+    }
+    return Optional.of(toAdd);
+  }
+
+  private int mapA2B(int a, ImmutableList<Edit> edits) {
+    if (edits.isEmpty()) {
+      // Magic special case of an unmodified file.
+      //
+      return a;
+    }
+
+    for (int i = 0; i < edits.size(); i++) {
+      final Edit e = edits.get(i);
+      if (a < e.getBeginA()) {
+        if (i == 0) {
+          // Special case of context at start of file.
+          //
+          return a;
+        }
+        return e.getBeginB() - (e.getBeginA() - a);
+      }
+      if (e.getBeginA() <= a && a <= e.getEndA()) {
+        return -1;
+      }
+    }
+
+    final Edit last = edits.get(edits.size() - 1);
+    return last.getEndB() + (a - last.getEndA());
+  }
+
+  private int mapB2A(int b, ImmutableList<Edit> edits) {
+    if (edits.isEmpty()) {
+      // Magic special case of an unmodified file.
+      //
+      return b;
+    }
+
+    for (int i = 0; i < edits.size(); i++) {
+      final Edit e = edits.get(i);
+      if (b < e.getBeginB()) {
+        if (i == 0) {
+          // Special case of context at start of file.
+          //
+          return b;
+        }
+        return e.getBeginA() - (e.getBeginB() - b);
+      }
+      if (e.getBeginB() <= b && b <= e.getEndB()) {
+        return -1;
+      }
+    }
+
+    final Edit last = edits.get(edits.size() - 1);
+    return last.getEndA() + (b - last.getEndB());
+  }
+
+  private DiffContent packContent(
+      TextSource a,
+      TextSource b,
+      boolean ignoredWhitespace,
+      ImmutableList<Edit> edits,
+      int context) {
+    SparseFileContentBuilder diffA = new SparseFileContentBuilder(a.size());
+    SparseFileContentBuilder diffB = new SparseFileContentBuilder(b.size());
+    EditList list = new EditList(edits, context, a.size(), b.size());
+    for (EditList.Hunk hunk : list.getHunks()) {
+      while (hunk.next()) {
+        if (hunk.isContextLine()) {
+          String lineA = a.getSourceLine(hunk.getCurA());
+          diffA.addLine(hunk.getCurA(), lineA);
+
+          if (ignoredWhitespace) {
+            // If we ignored whitespace in some form, also get the line
+            // from b when it does not exactly match the line from a.
+            //
+            String lineB = b.getSourceLine(hunk.getCurB());
+            if (!lineA.equals(lineB)) {
+              diffB.addLine(hunk.getCurB(), lineB);
+            }
+          }
+          hunk.incBoth();
+          continue;
+        }
+
+        if (hunk.isDeletedA()) {
+          a.copyLineTo(diffA, hunk.getCurA());
+          hunk.incA();
+        }
+
+        if (hunk.isInsertedB()) {
+          b.copyLineTo(diffB, hunk.getCurB());
+          hunk.incB();
+        }
+      }
+    }
+    return new DiffContent(diffA.build(), diffB.build());
+  }
+
+  /** Contains information to be displayed in line-by-line diff view. */
+  static class DiffCalculatorResult {
+    // This class is not @AutoValue, because Edit is mutable
+
+    /** Lines to be displayed */
+    final DiffContent diffContent;
+    /** List of edits including "fake" edits for unchanged lines with comments. */
+    final ImmutableList<Edit> edits;
+
+    DiffCalculatorResult(DiffContent diffContent, ImmutableList<Edit> edits) {
+      this.diffContent = diffContent;
+      this.edits = edits;
+    }
+  }
+
+  /** Lines to be displayed in line-by-line diff view. */
+  static class DiffContent {
+    /* All lines from the original text (i.e. srcA) to be displayed. */
+    final SparseFileContent a;
+    /**
+     * All lines from the new text (i.e. srcB) which are different than in original text. Lines are:
+     * a) All changed lines (i.e. if the content of the line was replaced with the new line) b) All
+     * inserted lines Note, that deleted lines are added to the a and are not added to b
+     */
+    final SparseFileContent b;
+
+    DiffContent(SparseFileContent a, SparseFileContent b) {
+      this.a = a;
+      this.b = b;
+    }
+  }
+
+  static class TextSource {
+    final Text src;
+
+    TextSource(Text src) {
+      this.src = src;
+    }
+
+    int size() {
+      if (src == null) {
+        return 0;
+      }
+      if (src.isMissingNewlineAtEnd()) {
+        return src.size();
+      }
+      return src.size() + 1;
+    }
+
+    void copyLineTo(SparseFileContentBuilder target, int lineNumber) {
+      target.addLine(lineNumber, getSourceLine(lineNumber));
+    }
+
+    private String getSourceLine(int lineNumber) {
+      return lineNumber >= src.size() ? "" : src.getString(lineNumber);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 15fa0f4..b663b9d 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -138,11 +138,10 @@
       throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
     ObjectId b = patchSet.commitId();
-    Whitespace ws = Whitespace.IGNORE_NONE;
     if (parentNum != null) {
-      return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
+      return get(PatchListKey.againstParentNum(parentNum, b, Whitespace.IGNORE_NONE), project);
     }
-    return get(PatchListKey.againstDefaultBase(b, ws), project);
+    return get(PatchListKey.againstDefaultBase(b, Whitespace.IGNORE_NONE), project);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index 625f56c..f37b925 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -37,7 +37,6 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
@@ -214,9 +213,10 @@
     return sizeDelta;
   }
 
-  public List<String> getHeaderLines() {
+  public ImmutableList<String> getHeaderLines() {
     final IntList m = RawParseUtils.lineMap(header, 0, header.length);
-    final List<String> headerLines = new ArrayList<>(m.size() - 1);
+    final ImmutableList.Builder<String> headerLines =
+        ImmutableList.builderWithExpectedSize(m.size() - 1);
     for (int i = 1; i < m.size() - 1; i++) {
       final int b = m.get(i);
       int e = m.get(i + 1);
@@ -225,7 +225,7 @@
       }
       headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
     }
-    return headerLines;
+    return headerLines.build();
   }
 
   Patch toPatch(PatchSet.Id setId) {
diff --git a/java/com/google/gerrit/server/patch/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
index bf38029..386c50d 100644
--- a/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -59,6 +59,12 @@
     return new PatchListKey(otherCommitId, newId, whitespace);
   }
 
+  public static PatchListKey againstBase(ObjectId id, int parentCount) {
+    return parentCount > 1
+        ? PatchListKey.againstParentNum(1, id, Whitespace.IGNORE_NONE)
+        : PatchListKey.againstDefaultBase(id, Whitespace.IGNORE_NONE);
+  }
+
   /**
    * Old patch-set ID
    *
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 1c1c639..92df794 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -15,34 +15,32 @@
 package com.google.gerrit.server.patch;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Comparator.comparing;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.prettify.common.EditList;
-import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.fixes.FixCalculator;
 import com.google.gerrit.server.mime.FileTypeRegistry;
+import com.google.gerrit.server.patch.DiffContentCalculator.DiffCalculatorResult;
+import com.google.gerrit.server.patch.DiffContentCalculator.TextSource;
 import com.google.inject.Inject;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.errors.CorruptObjectException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
@@ -53,39 +51,15 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 
 class PatchScriptBuilder {
-  static final int MAX_CONTEXT = 5000000;
-  static final int BIG_FILE = 9000;
 
-  private static final Comparator<Edit> EDIT_SORT = comparing(Edit::getBeginA);
-
-  private Repository db;
-  private Project.NameKey projectKey;
-  private ObjectReader reader;
   private Change change;
   private DiffPreferencesInfo diffPrefs;
-  private ComparisonType comparisonType;
-  private ObjectId aId;
-  private ObjectId bId;
-
-  private final Side a;
-  private final Side b;
-
-  private List<Edit> edits;
   private final FileTypeRegistry registry;
-  private final PatchListCache patchListCache;
-  private int context;
+  private IntraLineDiffCalculator intralineDiffCalculator;
 
   @Inject
-  PatchScriptBuilder(FileTypeRegistry ftr, PatchListCache plc) {
-    a = new Side();
-    b = new Side();
+  PatchScriptBuilder(FileTypeRegistry ftr) {
     registry = ftr;
-    patchListCache = plc;
-  }
-
-  void setRepository(Repository r, Project.NameKey projectKey) {
-    this.db = r;
-    this.projectKey = projectKey;
   }
 
   void setChange(Change c) {
@@ -94,107 +68,113 @@
 
   void setDiffPrefs(DiffPreferencesInfo dp) {
     diffPrefs = dp;
+  }
 
-    context = diffPrefs.context;
-    if (context == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
-      context = MAX_CONTEXT;
-    } else if (context > MAX_CONTEXT) {
-      context = MAX_CONTEXT;
+  void setIntraLineDiffCalculator(IntraLineDiffCalculator calculator) {
+    intralineDiffCalculator = calculator;
+  }
+
+  PatchScript toPatchScript(
+      Repository git,
+      PatchList list,
+      PatchListEntry content,
+      CommentDetail comments,
+      ImmutableList<Patch> history)
+      throws IOException {
+
+    PatchFileChange change =
+        new PatchFileChange(
+            content.getEdits(),
+            content.getEditsDueToRebase(),
+            content.getHeaderLines(),
+            content.getOldName(),
+            content.getNewName(),
+            content.getChangeType(),
+            content.getPatchType());
+    SidesResolver sidesResolver = new SidesResolver(git, list.getComparisonType());
+    ResolvedSides sides =
+        resolveSides(
+            git, sidesResolver, oldName(change), newName(change), list.getOldId(), list.getNewId());
+    return build(sides.a, sides.b, change, comments, history);
+  }
+
+  private ResolvedSides resolveSides(
+      Repository git,
+      SidesResolver sidesResolver,
+      String oldName,
+      String newName,
+      ObjectId aId,
+      ObjectId bId)
+      throws IOException {
+    try (ObjectReader reader = git.newObjectReader()) {
+      PatchSide a = sidesResolver.resolve(registry, reader, oldName, null, aId, true);
+      PatchSide b =
+          sidesResolver.resolve(registry, reader, newName, a, bId, Objects.equals(aId, bId));
+      return new ResolvedSides(a, b);
     }
   }
 
-  void setTrees(ComparisonType ct, ObjectId a, ObjectId b) {
-    comparisonType = ct;
-    aId = a;
-    bId = b;
+  PatchScript toPatchScript(
+      Repository git, ObjectId baseId, String fileName, List<FixReplacement> fixReplacements)
+      throws IOException, ResourceConflictException {
+    SidesResolver sidesResolver = new SidesResolver(git, ComparisonType.againstOtherPatchSet());
+    PatchSide a = resolveSideA(git, sidesResolver, fileName, baseId);
+    FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements);
+    PatchSide b =
+        new PatchSide(
+            null,
+            fileName,
+            ObjectId.zeroId(),
+            a.mode,
+            fixResult.text.getContent(),
+            fixResult.text,
+            a.mimeType,
+            a.displayMethod,
+            a.fileMode);
+
+    PatchFileChange change =
+        new PatchFileChange(
+            fixResult.edits,
+            ImmutableSet.of(),
+            ImmutableList.of(),
+            fileName,
+            fileName,
+            ChangeType.MODIFIED,
+            PatchType.UNIFIED);
+
+    return build(a, b, change, null, null);
   }
 
-  PatchScript toPatchScript(PatchListEntry content, CommentDetail comments, List<Patch> history)
+  private PatchSide resolveSideA(
+      Repository git, SidesResolver sidesResolver, String path, ObjectId baseId)
       throws IOException {
-    reader = db.newObjectReader();
-    try {
-      return build(content, comments, history);
-    } finally {
-      reader.close();
+    try (ObjectReader reader = git.newObjectReader()) {
+      return sidesResolver.resolve(registry, reader, path, null, baseId, true);
     }
   }
 
-  private PatchScript build(PatchListEntry content, CommentDetail comments, List<Patch> history)
-      throws IOException {
-    boolean intralineFailure = false;
-    boolean intralineTimeout = false;
+  private PatchScript build(
+      PatchSide a,
+      PatchSide b,
+      PatchFileChange content,
+      CommentDetail comments,
+      ImmutableList<Patch> history) {
 
-    a.path = oldName(content);
-    b.path = newName(content);
-
-    a.resolve(null, aId);
-    b.resolve(a, bId);
-
-    edits = new ArrayList<>(content.getEdits());
+    ImmutableList<Edit> contentEdits = content.getEdits();
     ImmutableSet<Edit> editsDueToRebase = content.getEditsDueToRebase();
 
-    if (isModify(content) && diffPrefs.intralineDifference && isIntralineModeAllowed(b)) {
-      IntraLineDiff d =
-          patchListCache.getIntraLineDiff(
-              IntraLineDiffKey.create(a.id, b.id, diffPrefs.ignoreWhitespace),
-              IntraLineDiffArgs.create(
-                  a.src, b.src, edits, editsDueToRebase, projectKey, bId, b.path));
-      if (d != null) {
-        switch (d.getStatus()) {
-          case EDIT_LIST:
-            edits = new ArrayList<>(d.getEdits());
-            break;
+    IntraLineDiffCalculatorResult intralineResult = IntraLineDiffCalculatorResult.NO_RESULT;
 
-          case DISABLED:
-            break;
-
-          case ERROR:
-            intralineFailure = true;
-            break;
-
-          case TIMEOUT:
-            intralineTimeout = true;
-            break;
-        }
-      } else {
-        intralineFailure = true;
-      }
+    if (isModify(content) && intralineDiffCalculator != null && isIntralineModeAllowed(b)) {
+      intralineResult =
+          intralineDiffCalculator.calculateIntraLineDiff(
+              contentEdits, editsDueToRebase, a.id, b.id, a.src, b.src, b.treeId, b.path);
     }
-
-    correctForDifferencesInNewlineAtEnd();
-
-    if (comments != null) {
-      ensureCommentsVisible(comments);
-    }
-
-    boolean hugeFile = false;
-    if (a.src == b.src && a.size() <= context && content.getEdits().isEmpty()) {
-      // Odd special case; the files are identical (100% rename or copy)
-      // and the user has asked for context that is larger than the file.
-      // Send them the entire file, with an empty edit after the last line.
-      //
-      for (int i = 0; i < a.size(); i++) {
-        a.addLine(i);
-      }
-      edits = new ArrayList<>(1);
-      edits.add(new Edit(a.size(), a.size()));
-
-    } else {
-      if (BIG_FILE < Math.max(a.size(), b.size())) {
-        // IF the file is really large, we disable things to avoid choking
-        // the browser client.
-        //
-        hugeFile = true;
-      }
-
-      // In order to expand the skipped common lines or syntax highlight the
-      // file properly we need to give the client the complete file contents.
-      // So force our context temporarily to the complete file size.
-      //
-      context = MAX_CONTEXT;
-
-      packContent(diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE);
-    }
+    ImmutableList<Edit> finalEdits = intralineResult.edits.orElse(contentEdits);
+    DiffContentCalculator calculator = new DiffContentCalculator(diffPrefs);
+    DiffCalculatorResult diffCalculatorResult =
+        calculator.calculateDiffContent(
+            new TextSource(a.src), new TextSource(b.src), finalEdits, comments);
 
     return new PatchScript(
         change.getKey(),
@@ -205,25 +185,23 @@
         b.fileMode,
         content.getHeaderLines(),
         diffPrefs,
-        a.dst,
-        b.dst,
-        edits,
+        diffCalculatorResult.diffContent.a,
+        diffCalculatorResult.diffContent.b,
+        diffCalculatorResult.edits,
         editsDueToRebase,
         a.displayMethod,
         b.displayMethod,
-        a.mimeType.toString(),
-        b.mimeType.toString(),
-        comments,
+        a.mimeType,
+        b.mimeType,
         history,
-        hugeFile,
-        intralineFailure,
-        intralineTimeout,
+        intralineResult.failure,
+        intralineResult.timeout,
         content.getPatchType() == Patch.PatchType.BINARY,
-        aId == null ? null : aId.getName(),
-        bId == null ? null : bId.getName());
+        a.treeId == null ? null : a.treeId.getName(),
+        b.treeId == null ? null : b.treeId.getName());
   }
 
-  private static boolean isModify(PatchListEntry content) {
+  private static boolean isModify(PatchFileChange content) {
     switch (content.getChangeType()) {
       case MODIFIED:
       case COPIED:
@@ -238,7 +216,7 @@
     }
   }
 
-  private static String oldName(PatchListEntry entry) {
+  private static String oldName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case ADDED:
         return null;
@@ -253,7 +231,7 @@
     }
   }
 
-  private static String newName(PatchListEntry entry) {
+  private static String newName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case DELETED:
         return null;
@@ -267,7 +245,7 @@
     }
   }
 
-  private static boolean isIntralineModeAllowed(Side side) {
+  private static boolean isIntralineModeAllowed(PatchSide side) {
     // The intraline diff cache keys are the same for these cases. It's better to not show
     // intraline results than showing completely wrong diffs or to run into a server error.
     return !Patch.isMagic(side.path) && !isSubmoduleCommit(side.mode);
@@ -277,339 +255,184 @@
     return mode.getObjectType() == Constants.OBJ_COMMIT;
   }
 
-  private void correctForDifferencesInNewlineAtEnd() {
-    // a.src.size() is the size ignoring a newline at the end whereas a.size() considers it.
-    int aSize = a.src.size();
-    int bSize = b.src.size();
+  private static class PatchSide {
+    final ObjectId treeId;
+    final String path;
+    final ObjectId id;
+    final FileMode mode;
+    final byte[] srcContent;
+    final Text src;
+    final String mimeType;
+    final DisplayMethod displayMethod;
+    final PatchScript.FileMode fileMode;
 
-    if (edits.isEmpty() && (aSize == 0 || bSize == 0)) {
-      // The diff was requested for a file which was either added or deleted but which JGit doesn't
-      // consider a file addition/deletion (e.g. requesting a diff for the old file name of a
-      // renamed file looks like a deletion).
-      return;
-    }
-
-    if (edits.isEmpty() && (aSize != bSize)) {
-      // Only edits due to rebase were present. If we now added the edits for the newlines, the
-      // code which later assembles the file contents would fail.
-      return;
-    }
-
-    Optional<Edit> lastEdit = getLast(edits);
-    if (isNewlineAtEndDeleted()) {
-      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndA() == aSize);
-      if (lastLineEdit.isPresent()) {
-        lastLineEdit.get().extendA();
-      } else {
-        Edit newlineEdit = new Edit(aSize, aSize + 1, bSize, bSize);
-        edits.add(newlineEdit);
-      }
-    } else if (isNewlineAtEndAdded()) {
-      Optional<Edit> lastLineEdit = lastEdit.filter(edit -> edit.getEndB() == bSize);
-      if (lastLineEdit.isPresent()) {
-        lastLineEdit.get().extendB();
-      } else {
-        Edit newlineEdit = new Edit(aSize, aSize, bSize, bSize + 1);
-        edits.add(newlineEdit);
-      }
+    private PatchSide(
+        ObjectId treeId,
+        String path,
+        ObjectId id,
+        FileMode mode,
+        byte[] srcContent,
+        Text src,
+        String mimeType,
+        DisplayMethod displayMethod,
+        PatchScript.FileMode fileMode) {
+      this.treeId = treeId;
+      this.path = path;
+      this.id = id;
+      this.mode = mode;
+      this.srcContent = srcContent;
+      this.src = src;
+      this.mimeType = mimeType;
+      this.displayMethod = displayMethod;
+      this.fileMode = fileMode;
     }
   }
 
-  private static <T> Optional<T> getLast(List<T> list) {
-    return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(list.size() - 1));
-  }
+  private static class ResolvedSides {
+    // Not an @AutoValue because PatchSide can't be AutoValue
+    public final PatchSide a;
+    public final PatchSide b;
 
-  private boolean isNewlineAtEndDeleted() {
-    return !a.src.isMissingNewlineAtEnd() && b.src.isMissingNewlineAtEnd();
-  }
-
-  private boolean isNewlineAtEndAdded() {
-    return a.src.isMissingNewlineAtEnd() && !b.src.isMissingNewlineAtEnd();
-  }
-
-  private void ensureCommentsVisible(CommentDetail comments) {
-    if (comments.getCommentsA().isEmpty() && comments.getCommentsB().isEmpty()) {
-      // No comments, no additional dummy edits are required.
-      //
-      return;
-    }
-
-    // Construct empty Edit blocks around each location where a comment is.
-    // This will force the later packContent method to include the regions
-    // containing comments, potentially combining those regions together if
-    // they have overlapping contexts. UI renders will also be able to make
-    // correct hunks from this, but because the Edit is empty they will not
-    // style it specially.
-    //
-    final List<Edit> empty = new ArrayList<>();
-    int lastLine;
-
-    lastLine = -1;
-    for (Comment c : comments.getCommentsA()) {
-      final int a = c.lineNbr;
-      if (lastLine != a) {
-        final int b = mapA2B(a - 1);
-        if (0 <= b) {
-          safeAdd(empty, new Edit(a - 1, b));
-        }
-        lastLine = a;
-      }
-    }
-
-    lastLine = -1;
-    for (Comment c : comments.getCommentsB()) {
-      int b = c.lineNbr;
-      if (lastLine != b) {
-        final int a = mapB2A(b - 1);
-        if (0 <= a) {
-          safeAdd(empty, new Edit(a, b - 1));
-        }
-        lastLine = b;
-      }
-    }
-
-    // Sort the final list by the index in A, so packContent can combine
-    // them correctly later.
-    //
-    edits.addAll(empty);
-    edits.sort(EDIT_SORT);
-  }
-
-  private void safeAdd(List<Edit> empty, Edit toAdd) {
-    final int a = toAdd.getBeginA();
-    final int b = toAdd.getBeginB();
-    for (Edit e : edits) {
-      if (e.getBeginA() <= a && a <= e.getEndA()) {
-        return;
-      }
-      if (e.getBeginB() <= b && b <= e.getEndB()) {
-        return;
-      }
-    }
-    empty.add(toAdd);
-  }
-
-  private int mapA2B(int a) {
-    if (edits.isEmpty()) {
-      // Magic special case of an unmodified file.
-      //
-      return a;
-    }
-
-    for (int i = 0; i < edits.size(); i++) {
-      final Edit e = edits.get(i);
-      if (a < e.getBeginA()) {
-        if (i == 0) {
-          // Special case of context at start of file.
-          //
-          return a;
-        }
-        return e.getBeginB() - (e.getBeginA() - a);
-      }
-      if (e.getBeginA() <= a && a <= e.getEndA()) {
-        return -1;
-      }
-    }
-
-    final Edit last = edits.get(edits.size() - 1);
-    return last.getEndB() + (a - last.getEndA());
-  }
-
-  private int mapB2A(int b) {
-    if (edits.isEmpty()) {
-      // Magic special case of an unmodified file.
-      //
-      return b;
-    }
-
-    for (int i = 0; i < edits.size(); i++) {
-      final Edit e = edits.get(i);
-      if (b < e.getBeginB()) {
-        if (i == 0) {
-          // Special case of context at start of file.
-          //
-          return b;
-        }
-        return e.getBeginA() - (e.getBeginB() - b);
-      }
-      if (e.getBeginB() <= b && b <= e.getEndB()) {
-        return -1;
-      }
-    }
-
-    final Edit last = edits.get(edits.size() - 1);
-    return last.getEndA() + (b - last.getEndB());
-  }
-
-  private void packContent(boolean ignoredWhitespace) {
-    EditList list = new EditList(edits, context, a.size(), b.size());
-    for (EditList.Hunk hunk : list.getHunks()) {
-      while (hunk.next()) {
-        if (hunk.isContextLine()) {
-          String lineA = a.getSourceLine(hunk.getCurA());
-          a.dst.addLine(hunk.getCurA(), lineA);
-
-          if (ignoredWhitespace) {
-            // If we ignored whitespace in some form, also get the line
-            // from b when it does not exactly match the line from a.
-            //
-            String lineB = b.getSourceLine(hunk.getCurB());
-            if (!lineA.equals(lineB)) {
-              b.dst.addLine(hunk.getCurB(), lineB);
-            }
-          }
-          hunk.incBoth();
-          continue;
-        }
-
-        if (hunk.isDeletedA()) {
-          a.addLine(hunk.getCurA());
-          hunk.incA();
-        }
-
-        if (hunk.isInsertedB()) {
-          b.addLine(hunk.getCurB());
-          hunk.incB();
-        }
-      }
+    ResolvedSides(PatchSide a, PatchSide b) {
+      this.a = a;
+      this.b = b;
     }
   }
 
-  private class Side {
-    String path;
-    ObjectId id;
-    FileMode mode;
-    byte[] srcContent;
-    Text src;
-    MimeType mimeType = MimeUtil2.UNKNOWN_MIME_TYPE;
-    DisplayMethod displayMethod = DisplayMethod.DIFF;
-    PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
-    final SparseFileContent dst = new SparseFileContent();
+  static class SidesResolver {
 
-    int size() {
-      if (src == null) {
-        return 0;
-      }
-      if (src.isMissingNewlineAtEnd()) {
-        return src.size();
-      }
-      return src.size() + 1;
+    private final Repository db;
+    private final ComparisonType comparisonType;
+
+    SidesResolver(Repository db, ComparisonType comparisonType) {
+      this.db = db;
+      this.comparisonType = comparisonType;
     }
 
-    void addLine(int lineNumber) {
-      String lineContent = getSourceLine(lineNumber);
-      dst.addLine(lineNumber, lineContent);
-    }
-
-    String getSourceLine(int lineNumber) {
-      return lineNumber >= src.size() ? "" : src.getString(lineNumber);
-    }
-
-    void resolve(Side other, ObjectId within) throws IOException {
+    PatchSide resolve(
+        final FileTypeRegistry registry,
+        final ObjectReader reader,
+        final String path,
+        final PatchSide other,
+        final ObjectId within,
+        final boolean isWithinEqualsA)
+        throws IOException {
       try {
-        final boolean reuse;
-        if (Patch.COMMIT_MSG.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
-            id = ObjectId.zeroId();
-            src = Text.EMPTY;
-            srcContent = Text.NO_BYTES;
+        boolean isCommitMsg = Patch.COMMIT_MSG.equals(path);
+        boolean isMergeList = Patch.MERGE_LIST.equals(path);
+        if (isCommitMsg || isMergeList) {
+          if (comparisonType.isAgainstParentOrAutoMerge() && isWithinEqualsA) {
+            return createSide(
+                within,
+                path,
+                ObjectId.zeroId(),
+                FileMode.MISSING,
+                Text.NO_BYTES,
+                Text.EMPTY,
+                MimeUtil2.UNKNOWN_MIME_TYPE.toString(),
+                DisplayMethod.NONE,
+                false);
+          }
+          Text src =
+              isCommitMsg
+                  ? Text.forCommit(reader, within)
+                  : Text.forMergeList(comparisonType, reader, within);
+          byte[] srcContent = src.getContent();
+          DisplayMethod displayMethod;
+          FileMode mode;
+          if (src == Text.EMPTY) {
             mode = FileMode.MISSING;
             displayMethod = DisplayMethod.NONE;
           } else {
-            id = within;
-            src = Text.forCommit(reader, within);
-            srcContent = src.getContent();
-            if (src == Text.EMPTY) {
-              mode = FileMode.MISSING;
-              displayMethod = DisplayMethod.NONE;
-            } else {
-              mode = FileMode.REGULAR_FILE;
-            }
+            mode = FileMode.REGULAR_FILE;
+            displayMethod = DisplayMethod.DIFF;
           }
-          reuse = false;
-        } else if (Patch.MERGE_LIST.equals(path)) {
-          if (comparisonType.isAgainstParentOrAutoMerge() && Objects.equals(aId, within)) {
-            id = ObjectId.zeroId();
-            src = Text.EMPTY;
-            srcContent = Text.NO_BYTES;
-            mode = FileMode.MISSING;
-            displayMethod = DisplayMethod.NONE;
-          } else {
-            id = within;
-            src = Text.forMergeList(comparisonType, reader, within);
-            srcContent = src.getContent();
-            if (src == Text.EMPTY) {
-              mode = FileMode.MISSING;
-              displayMethod = DisplayMethod.NONE;
-            } else {
-              mode = FileMode.REGULAR_FILE;
-            }
-          }
-          reuse = false;
+          return createSide(
+              within,
+              path,
+              within,
+              mode,
+              srcContent,
+              src,
+              MimeUtil2.UNKNOWN_MIME_TYPE.toString(),
+              displayMethod,
+              false);
+        }
+        final TreeWalk tw = find(reader, path, within);
+        ObjectId id = tw != null ? tw.getObjectId(0) : ObjectId.zeroId();
+        FileMode mode = tw != null ? tw.getFileMode(0) : FileMode.MISSING;
+        boolean reuse =
+            other != null
+                && other.id.equals(id)
+                && (other.mode == mode || isBothFile(other.mode, mode));
+        Text src = null;
+        byte[] srcContent;
+        if (reuse) {
+          srcContent = other.srcContent;
+
+        } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
+          srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
+
+        } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
+          String strContent = "Subproject commit " + ObjectId.toString(id);
+          srcContent = strContent.getBytes(UTF_8);
+
         } else {
-          final TreeWalk tw = find(within);
+          srcContent = Text.NO_BYTES;
+        }
+        String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
+        DisplayMethod displayMethod = DisplayMethod.DIFF;
+        if (reuse) {
+          mimeType = other.mimeType;
+          displayMethod = other.displayMethod;
+          src = other.src;
 
-          id = tw != null ? tw.getObjectId(0) : ObjectId.zeroId();
-          mode = tw != null ? tw.getFileMode(0) : FileMode.MISSING;
-          reuse =
-              other != null
-                  && other.id.equals(id)
-                  && (other.mode == mode || isBothFile(other.mode, mode));
-
-          if (reuse) {
-            srcContent = other.srcContent;
-
-          } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
-            srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
-
-          } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
-            String strContent = "Subproject commit " + ObjectId.toString(id);
-            srcContent = strContent.getBytes(UTF_8);
-
-          } else {
-            srcContent = Text.NO_BYTES;
+        } else if (srcContent.length > 0 && FileMode.SYMLINK != mode) {
+          MimeType registryMimeType = registry.getMimeType(path, srcContent);
+          if ("image".equals(registryMimeType.getMediaType())
+              && registry.isSafeInline(registryMimeType)) {
+            displayMethod = DisplayMethod.IMG;
           }
-
-          if (reuse) {
-            mimeType = other.mimeType;
-            displayMethod = other.displayMethod;
-            src = other.src;
-
-          } else if (srcContent.length > 0 && FileMode.SYMLINK != mode) {
-            mimeType = registry.getMimeType(path, srcContent);
-            if ("image".equals(mimeType.getMediaType()) && registry.isSafeInline(mimeType)) {
-              displayMethod = DisplayMethod.IMG;
-            }
-          }
+          mimeType = registryMimeType.toString();
         }
+        return createSide(within, path, id, mode, srcContent, src, mimeType, displayMethod, reuse);
 
-        if (mode == FileMode.MISSING) {
-          displayMethod = DisplayMethod.NONE;
-        }
-
-        if (!reuse) {
-          if (srcContent == Text.NO_BYTES) {
-            src = Text.EMPTY;
-          } else {
-            src = new Text(srcContent);
-          }
-        }
-
-        dst.setSize(size());
-
-        if (mode == FileMode.SYMLINK) {
-          fileMode = PatchScript.FileMode.SYMLINK;
-        } else if (mode == FileMode.GITLINK) {
-          fileMode = PatchScript.FileMode.GITLINK;
-        }
       } catch (IOException err) {
         throw new IOException("Cannot read " + within.name() + ":" + path, err);
       }
     }
 
-    private TreeWalk find(ObjectId within)
-        throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
-            IOException {
+    private PatchSide createSide(
+        ObjectId treeId,
+        String path,
+        ObjectId id,
+        FileMode mode,
+        byte[] srcContent,
+        Text src,
+        String mimeType,
+        DisplayMethod displayMethod,
+        boolean reuse) {
+      if (!reuse) {
+        if (srcContent == Text.NO_BYTES) {
+          src = Text.EMPTY;
+        } else {
+          src = new Text(srcContent);
+        }
+      }
+      if (mode == FileMode.MISSING) {
+        displayMethod = DisplayMethod.NONE;
+      }
+      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
+      if (mode == FileMode.SYMLINK) {
+        fileMode = PatchScript.FileMode.SYMLINK;
+      } else if (mode == FileMode.GITLINK) {
+        fileMode = PatchScript.FileMode.GITLINK;
+      }
+      return new PatchSide(
+          treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
+    }
+
+    private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException {
       if (path == null || within == null) {
         return null;
       }
@@ -624,4 +447,97 @@
     return (a.getBits() & FileMode.TYPE_FILE) == FileMode.TYPE_FILE
         && (b.getBits() & FileMode.TYPE_FILE) == FileMode.TYPE_FILE;
   }
+
+  static class IntraLineDiffCalculatorResult {
+    // Not an @AutoValue because Edit is mutable
+    final boolean failure;
+    final boolean timeout;
+    private final Optional<ImmutableList<Edit>> edits;
+
+    private IntraLineDiffCalculatorResult(
+        Optional<ImmutableList<Edit>> edits, boolean failure, boolean timeout) {
+      this.failure = failure;
+      this.timeout = timeout;
+      this.edits = edits;
+    }
+
+    static final IntraLineDiffCalculatorResult NO_RESULT =
+        new IntraLineDiffCalculatorResult(Optional.empty(), false, false);
+    static final IntraLineDiffCalculatorResult FAILURE =
+        new IntraLineDiffCalculatorResult(Optional.empty(), true, false);
+    static final IntraLineDiffCalculatorResult TIMEOUT =
+        new IntraLineDiffCalculatorResult(Optional.empty(), false, true);
+
+    static IntraLineDiffCalculatorResult success(ImmutableList<Edit> edits) {
+      return new IntraLineDiffCalculatorResult(Optional.of(edits), false, false);
+    }
+  }
+
+  interface IntraLineDiffCalculator {
+
+    IntraLineDiffCalculatorResult calculateIntraLineDiff(
+        ImmutableList<Edit> edits,
+        Set<Edit> editsDueToRebase,
+        ObjectId aId,
+        ObjectId bId,
+        Text aSrc,
+        Text bSrc,
+        ObjectId bTreeId,
+        String bPath);
+  }
+
+  static class PatchFileChange {
+    private final ImmutableList<Edit> edits;
+    private final ImmutableSet<Edit> editsDueToRebase;
+    private final ImmutableList<String> headerLines;
+    private final String oldName;
+    private final String newName;
+    private final ChangeType changeType;
+    private final Patch.PatchType patchType;
+
+    public PatchFileChange(
+        ImmutableList<Edit> edits,
+        ImmutableSet<Edit> editsDueToRebase,
+        ImmutableList<String> headerLines,
+        String oldName,
+        String newName,
+        ChangeType changeType,
+        Patch.PatchType patchType) {
+      this.edits = edits;
+      this.editsDueToRebase = editsDueToRebase;
+      this.headerLines = headerLines;
+      this.oldName = oldName;
+      this.newName = newName;
+      this.changeType = changeType;
+      this.patchType = patchType;
+    }
+
+    ImmutableList<Edit> getEdits() {
+      return edits;
+    }
+
+    ImmutableSet<Edit> getEditsDueToRebase() {
+      return editsDueToRebase;
+    }
+
+    ImmutableList<String> getHeaderLines() {
+      return headerLines;
+    }
+
+    String getNewName() {
+      return newName;
+    }
+
+    String getOldName() {
+      return oldName;
+    }
+
+    ChangeType getChangeType() {
+      return changeType;
+    }
+
+    Patch.PatchType getPatchType() {
+      return patchType;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 319a5bc..b7b3d81 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.CommentDetail;
@@ -27,6 +28,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -38,6 +40,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LargeObjectException;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchScriptBuilder.IntraLineDiffCalculatorResult;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -48,20 +51,22 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.Callable;
+import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 public class PatchScriptFactory implements Callable<PatchScript> {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
+
     PatchScriptFactory create(
         ChangeNotes notes,
         String fileName,
@@ -92,17 +97,12 @@
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private Optional<ChangeEdit> edit;
 
   private final Change.Id changeId;
   private boolean loadHistory = true;
   private boolean loadComments = true;
 
   private ChangeNotes notes;
-  private ObjectId aId;
-  private ObjectId bId;
-  private List<Patch> history;
-  private CommentDetail comments;
 
   @AssistedInject
   PatchScriptFactory(
@@ -189,23 +189,6 @@
   public PatchScript call()
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
-    validatePatchSetId(psa);
-    validatePatchSetId(psb);
-
-    if (psa != null) {
-      checkState(parentNum < 0, "expected no parentNum when psa is present");
-      checkArgument(psa.get() != 0, "edit not supported for left side");
-      aId = getCommitId(psa);
-    } else {
-      aId = null;
-    }
-
-    if (psb.get() != 0) {
-      bId = getCommitId(psb);
-    } else {
-      // Change edit: create synthetic PatchSet corresponding to the edit.
-      bId = getEditRev();
-    }
 
     try {
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
@@ -219,13 +202,31 @@
 
     try (Repository git = repoManager.openRepository(notes.getProjectName())) {
       try {
-        final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
-        final PatchScriptBuilder b = newBuilder(list, git);
+        validatePatchSetId(psa);
+        validatePatchSetId(psb);
+
+        ObjectId aId = getAId().orElse(null);
+        ObjectId bId = getBId().orElse(null);
+        boolean changeEdit = false;
+        if (bId == null) {
+          // Change edit: create synthetic PatchSet corresponding to the edit.
+          Optional<ChangeEdit> edit = editReader.byChange(notes);
+          if (!edit.isPresent()) {
+            throw new NoSuchChangeException(notes.getChangeId());
+          }
+          bId = edit.get().getEditCommit();
+          changeEdit = true;
+        }
+
+        final PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
+        final PatchScriptBuilder b = newBuilder();
         final PatchListEntry content = list.get(fileName);
 
-        loadCommentsAndHistory(content.getChangeType(), content.getOldName(), content.getNewName());
+        Optional<ImmutableList<Patch>> history = loadHistory(content, changeEdit);
+        Optional<CommentDetail> comments =
+            loadComments(content, changeEdit, history.orElse(ImmutableList.of()));
 
-        return b.toPatchScript(content, comments, history);
+        return b.toPatchScript(git, list, content, comments.orElse(null), history.orElse(null));
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
       } catch (IOException e) {
@@ -243,7 +244,46 @@
     }
   }
 
-  private PatchListKey keyFor(Whitespace whitespace) {
+  private Optional<CommentDetail> loadComments(
+      PatchListEntry content, boolean changeEdit, ImmutableList<Patch> history) {
+    if (!loadComments) {
+      return Optional.empty();
+    }
+    return new CommentsLoader(psa, psb, userProvider, notes, commentsUtil)
+        .load(
+            changeEdit,
+            content.getChangeType(),
+            content.getOldName(),
+            content.getNewName(),
+            history);
+  }
+
+  private Optional<ImmutableList<Patch>> loadHistory(PatchListEntry content, boolean changeEdit) {
+    if (!loadHistory) {
+      return Optional.empty();
+    }
+    HistoryLoader loader = new HistoryLoader(psa, psb, psUtil, notes, fileName);
+    return Optional.of(loader.load(changeEdit, content.getChangeType(), content.getOldName()));
+  }
+
+  private Optional<ObjectId> getAId() {
+    if (psa == null) {
+      return Optional.empty();
+    }
+    checkState(parentNum < 0, "expected no parentNum when psa is present");
+    checkArgument(psa.get() != 0, "edit not supported for left side");
+    return Optional.of(getCommitId(psa));
+  }
+
+  private Optional<ObjectId> getBId() {
+    if (psb.get() == 0) {
+      // Change edit
+      return Optional.empty();
+    }
+    return Optional.of(getCommitId(psb));
+  }
+
+  private PatchListKey keyFor(ObjectId aId, ObjectId bId, Whitespace whitespace) {
     if (parentNum < 0) {
       return PatchListKey.againstCommit(aId, bId, whitespace);
     }
@@ -254,12 +294,14 @@
     return patchListCache.get(key, notes.getProjectName());
   }
 
-  private PatchScriptBuilder newBuilder(PatchList list, Repository git) {
+  private PatchScriptBuilder newBuilder() {
     final PatchScriptBuilder b = builderFactory.get();
-    b.setRepository(git, notes.getProjectName());
     b.setChange(notes.getChange());
     b.setDiffPrefs(diffPrefs);
-    b.setTrees(list.getComparisonType(), list.getOldId(), list.getNewId());
+    if (diffPrefs.intralineDifference) {
+      b.setIntraLineDiffCalculator(
+          new IntraLineDiffCalculator(patchListCache, notes.getProjectName(), diffPrefs));
+    }
     return b;
   }
 
@@ -271,14 +313,6 @@
     return ps.commitId();
   }
 
-  private ObjectId getEditRev() throws AuthException, IOException {
-    edit = editReader.byChange(notes);
-    if (edit.isPresent()) {
-      return edit.get().getEditCommit();
-    }
-    throw new NoSuchChangeException(notes.getChangeId());
-  }
-
   private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
     if (psId == null) { // OK, means use base;
     } else if (changeId.equals(psId.changeId())) { // OK, same change;
@@ -287,16 +321,29 @@
     }
   }
 
-  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName) {
-    Map<Patch.Key, Patch> byKey = new HashMap<>();
+  private static class HistoryLoader {
+    private final PatchSet.Id psa;
+    private final PatchSet.Id psb;
+    private final PatchSetUtil psUtil;
+    private final ChangeNotes notes;
+    private final String fileName;
 
-    if (loadHistory) {
+    HistoryLoader(
+        PatchSet.Id psa, PatchSet.Id psb, PatchSetUtil psUtil, ChangeNotes notes, String fileName) {
+      this.psa = psa;
+      this.psb = psb;
+      this.psUtil = psUtil;
+      this.notes = notes;
+      this.fileName = fileName;
+    }
+
+    private ImmutableList<Patch> load(boolean changeEdit, ChangeType changeType, String oldName) {
       // This seems like a cheap trick. It doesn't properly account for a
       // file that gets renamed between patch set 1 and patch set 2. We
       // will wind up packing the wrong Patch object because we didn't do
       // proper rename detection between the patch sets.
       //
-      history = new ArrayList<>();
+      ImmutableList.Builder<Patch> historyBuilder = ImmutableList.builder();
       for (PatchSet ps : psUtil.byChange(notes)) {
         String name = fileName;
         if (psa != null) {
@@ -317,17 +364,51 @@
         }
 
         Patch p = new Patch(Patch.key(ps.id(), name));
-        history.add(p);
-        byKey.put(p.getKey(), p);
+        historyBuilder.add(p);
       }
-      if (edit != null && edit.isPresent()) {
+      if (changeEdit) {
         Patch p = new Patch(Patch.key(PatchSet.id(psb.changeId(), 0), fileName));
-        history.add(p);
-        byKey.put(p.getKey(), p);
+        historyBuilder.add(p);
       }
+      return historyBuilder.build();
+    }
+  }
+
+  private static class CommentsLoader {
+    private final PatchSet.Id psa;
+    private final PatchSet.Id psb;
+    private final Provider<CurrentUser> userProvider;
+    private final ChangeNotes notes;
+    private final CommentsUtil commentsUtil;
+    private CommentDetail comments;
+
+    CommentsLoader(
+        PatchSet.Id psa,
+        PatchSet.Id psb,
+        Provider<CurrentUser> userProvider,
+        ChangeNotes notes,
+        CommentsUtil commentsUtil) {
+      this.psa = psa;
+      this.psb = psb;
+      this.userProvider = userProvider;
+      this.notes = notes;
+      this.commentsUtil = commentsUtil;
     }
 
-    if (loadComments && edit == null) {
+    private Optional<CommentDetail> load(
+        boolean changeEdit,
+        ChangeType changeType,
+        String oldName,
+        String newName,
+        ImmutableList<Patch> history) {
+      // TODO: Implement this method with CommentDetailBuilder (this class doesn't exists yet).
+      // This is a legacy code which create final object and populate it and then returns it.
+      if (changeEdit) {
+        return Optional.empty();
+      }
+      Map<Patch.Key, Patch> byKey = new HashMap<>();
+      history.forEach(p -> byKey.put(p.getKey(), p));
+
       comments = new CommentDetail(psa, psb);
       switch (changeType) {
         case ADDED:
@@ -376,29 +457,79 @@
             break;
         }
       }
+      return Optional.of(comments);
     }
-  }
 
-  private void loadPublished(Map<Patch.Key, Patch> byKey, String file) {
-    for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
-      comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = Patch.key(psId, c.key.filename);
-      Patch p = byKey.get(pKey);
-      if (p != null) {
-        p.setCommentCount(p.getCommentCount() + 1);
+    private void loadPublished(Map<Patch.Key, Patch> byKey, String file) {
+      for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
+        comments.include(notes.getChangeId(), c);
+        PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+        Patch.Key pKey = Patch.key(psId, c.key.filename);
+        Patch p = byKey.get(pKey);
+        if (p != null) {
+          p.setCommentCount(p.getCommentCount() + 1);
+        }
+      }
+    }
+
+    private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file) {
+      for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
+        comments.include(notes.getChangeId(), c);
+        PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
+        Patch.Key pKey = Patch.key(psId, c.key.filename);
+        Patch p = byKey.get(pKey);
+        if (p != null) {
+          p.setDraftCount(p.getDraftCount() + 1);
+        }
       }
     }
   }
 
-  private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file) {
-    for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
-      comments.include(notes.getChangeId(), c);
-      PatchSet.Id psId = PatchSet.id(notes.getChangeId(), c.key.patchSetId);
-      Patch.Key pKey = Patch.key(psId, c.key.filename);
-      Patch p = byKey.get(pKey);
-      if (p != null) {
-        p.setDraftCount(p.getDraftCount() + 1);
+  private static class IntraLineDiffCalculator
+      implements PatchScriptBuilder.IntraLineDiffCalculator {
+
+    private final PatchListCache patchListCache;
+    private final Project.NameKey projectKey;
+    private final DiffPreferencesInfo diffPrefs;
+
+    IntraLineDiffCalculator(
+        PatchListCache patchListCache, Project.NameKey projectKey, DiffPreferencesInfo diffPrefs) {
+      this.patchListCache = patchListCache;
+      this.projectKey = projectKey;
+      this.diffPrefs = diffPrefs;
+    }
+
+    @Override
+    public IntraLineDiffCalculatorResult calculateIntraLineDiff(
+        ImmutableList<Edit> edits,
+        Set<Edit> editsDueToRebase,
+        ObjectId aId,
+        ObjectId bId,
+        Text aSrc,
+        Text bSrc,
+        ObjectId bTreeId,
+        String bPath) {
+      IntraLineDiff d =
+          patchListCache.getIntraLineDiff(
+              IntraLineDiffKey.create(aId, bId, diffPrefs.ignoreWhitespace),
+              IntraLineDiffArgs.create(
+                  aSrc, bSrc, edits, editsDueToRebase, projectKey, bTreeId, bPath));
+      if (d == null) {
+        return IntraLineDiffCalculatorResult.FAILURE;
+      }
+      switch (d.getStatus()) {
+        case EDIT_LIST:
+          return IntraLineDiffCalculatorResult.success(d.getEdits());
+
+        case ERROR:
+          return IntraLineDiffCalculatorResult.FAILURE;
+
+        case TIMEOUT:
+          return IntraLineDiffCalculatorResult.TIMEOUT;
+
+        case DISABLED:
+        default:
+          return IntraLineDiffCalculatorResult.NO_RESULT;
       }
     }
   }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
new file mode 100644
index 0000000..2e39607
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.concurrent.Callable;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+public class PatchScriptFactoryForAutoFix implements Callable<PatchScript> {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+
+    PatchScriptFactoryForAutoFix create(
+        Repository git,
+        ChangeNotes notes,
+        String fileName,
+        PatchSet patchSet,
+        ImmutableList<FixReplacement> fixReplacements,
+        DiffPreferencesInfo diffPrefs);
+  }
+
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+  private final Change.Id changeId;
+  private final ChangeNotes notes;
+  private final Provider<PatchScriptBuilder> builderFactory;
+  private final Repository git;
+  private final PatchSet patchSet;
+  private final String fileName;
+  private final DiffPreferencesInfo diffPrefs;
+  private final ImmutableList<FixReplacement> fixReplacements;
+
+  @AssistedInject
+  PatchScriptFactoryForAutoFix(
+      Provider<PatchScriptBuilder> builderFactory,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      @Assisted Repository git,
+      @Assisted ChangeNotes notes,
+      @Assisted String fileName,
+      @Assisted PatchSet patchSet,
+      @Assisted ImmutableList<FixReplacement> fixReplacements,
+      @Assisted DiffPreferencesInfo diffPrefs) {
+    this.notes = notes;
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+    this.changeId = patchSet.id().changeId();
+    this.git = git;
+    this.patchSet = patchSet;
+    this.fileName = fileName;
+    this.fixReplacements = fixReplacements;
+    this.builderFactory = builderFactory;
+    this.diffPrefs = diffPrefs;
+  }
+
+  @Override
+  public PatchScript call()
+      throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
+          PermissionBackendException {
+
+    try {
+      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new NoSuchChangeException(changeId, e);
+    }
+
+    if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
+      throw new NoSuchChangeException(changeId);
+    }
+
+    return createPatchScript();
+  }
+
+  private PatchScript createPatchScript() throws LargeObjectException {
+    checkState(patchSet.id().get() != 0, "edit not supported for left side");
+    PatchScriptBuilder b = newBuilder();
+    try {
+      ObjectId baseId = patchSet.commitId();
+      return b.toPatchScript(git, baseId, fileName, fixReplacements);
+    } catch (ResourceConflictException e) {
+      logger.atSevere().withCause(e).log("AutoFix replacements is not valid");
+      throw new IllegalStateException("AutoFix replacements is not valid", e);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("File content unavailable");
+      throw new NoSuchChangeException(notes.getChangeId(), e);
+    } catch (org.eclipse.jgit.errors.LargeObjectException err) {
+      throw new LargeObjectException("File content is too large", err);
+    }
+  }
+
+  private PatchScriptBuilder newBuilder() {
+    PatchScriptBuilder b = builderFactory.get();
+    b.setChange(notes.getChange());
+    b.setDiffPrefs(diffPrefs);
+    return b;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 07cb50d..253f50c 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -89,7 +89,7 @@
   }
 
   /** Can this user see this change? */
-  private boolean isVisible(@Nullable ChangeData cd) {
+  private boolean isVisible(ChangeData cd) {
     if (getChange().isPrivate() && !isPrivateVisible(cd)) {
       return false;
     }
@@ -144,7 +144,7 @@
 
   /** Is this user assigned to this change? */
   private boolean isAssignee() {
-    Account.Id currentAssignee = notes.getChange().getAssignee();
+    Account.Id currentAssignee = getChange().getAssignee();
     if (currentAssignee != null && getUser().isIdentifiedUser()) {
       Account.Id id = getUser().getAccountId();
       return id.equals(currentAssignee);
@@ -153,9 +153,8 @@
   }
 
   /** Is this user a reviewer for the change? */
-  private boolean isReviewer(@Nullable ChangeData cd) {
+  private boolean isReviewer(ChangeData cd) {
     if (getUser().isIdentifiedUser()) {
-      cd = cd != null ? cd : changeDataFactory.create(notes);
       Collection<Account.Id> results = cd.reviewers().all();
       return results.contains(getUser().getAccountId());
     }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 16bbdaf..47a48b7 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -20,11 +20,10 @@
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.entities.RefNames.REFS_USERS_SELF;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toCollection;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
@@ -60,6 +59,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -129,7 +129,7 @@
   }
 
   /** Filters given refs and tags by visibility. */
-  Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+  Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
     logger.atFinest().log(
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
@@ -145,10 +145,10 @@
 
     // See if we can get away with a single, cheap ref evaluation.
     if (refs.size() == 1) {
-      String refName = Iterables.getOnlyElement(refs.values()).getName();
+      String refName = Iterables.getOnlyElement(refs).getName();
       if (opts.filterMeta() && isMetadata(refName)) {
         logger.atFinest().log("Filter out metadata ref %s", refName);
-        return ImmutableMap.of();
+        return ImmutableList.of();
       }
       if (RefNames.isRefsChanges(refName)) {
         boolean isChangeRefVisisble = canSeeSingleChangeRef(refName);
@@ -157,18 +157,18 @@
           return refs;
         }
         logger.atFinest().log("Filter out non-visible change ref %s", refName);
-        return ImmutableMap.of();
+        return ImmutableList.of();
       }
     }
 
     // Perform an initial ref filtering with all the refs the caller asked for. If we find tags that
     // we have to investigate separately (deferred tags) then perform a reachability check starting
     // from all visible branches (refs/heads/*).
-    Result initialRefFilter = filterRefs(refs, repo, opts);
-    Map<String, Ref> visibleRefs = initialRefFilter.visibleRefs();
+    Result initialRefFilter = filterRefs(new ArrayList<>(refs), repo, opts);
+    List<Ref> visibleRefs = initialRefFilter.visibleRefs();
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
-        Result allVisibleBranches = filterRefs(getTaggableRefsMap(repo), repo, opts);
+        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), repo, opts);
         checkState(
             allVisibleBranches.deferredTags().isEmpty(),
             "unexpected tags found when filtering refs/heads/* "
@@ -177,12 +177,12 @@
         TagMatcher tags =
             tagCache
                 .get(projectState.getNameKey())
-                .matcher(tagCache, repo, allVisibleBranches.visibleRefs().values());
+                .matcher(tagCache, repo, allVisibleBranches.visibleRefs());
         for (Ref tag : initialRefFilter.deferredTags()) {
           try {
             if (tags.isReachable(tag)) {
               logger.atFinest().log("Include reachable tag %s", tag.getName());
-              visibleRefs.put(tag.getName(), tag);
+              visibleRefs.add(tag);
             } else {
               logger.atFinest().log("Filter out non-reachable tag %s", tag.getName());
             }
@@ -202,7 +202,7 @@
    * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
    * compute will be returned as part of {@link Result#visibleRefs()}.
    */
-  Result filterRefs(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+  Result filterRefs(List<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
     logger.atFinest().log("Filter refs (refs = %s)", refs);
 
@@ -252,9 +252,9 @@
       identifiedUser = null;
     }
 
-    Map<String, Ref> resultRefs = new HashMap<>();
+    List<Ref> resultRefs = new ArrayList<>(refs.size());
     List<Ref> deferredTags = new ArrayList<>();
-    for (Ref ref : refs.values()) {
+    for (Ref ref : refs) {
       String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
@@ -268,7 +268,7 @@
         // Edits are visible only to the owning user, if change is visible.
         if (viewMetadata || visibleEdit(repo, name)) {
           logger.atFinest().log("Include edit ref %s", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           logger.atFinest().log("Filter out edit ref %s", name);
         }
@@ -276,7 +276,7 @@
         // Change ref is visible only if the change is visible.
         if (viewMetadata || visible(repo, changeId)) {
           logger.atFinest().log("Include change ref %s", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           logger.atFinest().log("Filter out change ref %s", name);
         }
@@ -284,7 +284,7 @@
         // Account ref is visible only to the corresponding account.
         if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
           logger.atFinest().log("Include user ref %s", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           logger.atFinest().log("Filter out user ref %s", name);
         }
@@ -296,7 +296,7 @@
                 && isGroupOwner(group, identifiedUser, isAdmin)
                 && canReadRef(name))) {
           logger.atFinest().log("Include group ref %s", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           logger.atFinest().log("Filter out group ref %s", name);
         }
@@ -312,7 +312,7 @@
           // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
           // is a negligible risk.
           logger.atFinest().log("Include tag ref %s because user has read on refs/*", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           // If its a tag, consider it later.
           if (ref.getObjectId() != null) {
@@ -326,7 +326,7 @@
         // Sequences are internal database implementation details.
         if (viewMetadata) {
           logger.atFinest().log("Include sequence ref %s", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           logger.atFinest().log("Filter out sequence ref %s", name);
         }
@@ -336,7 +336,7 @@
         // users.
         if (viewMetadata) {
           logger.atFinest().log("Include external IDs branch %s", name);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         } else {
           logger.atFinest().log("Filter out external IDs branch %s", name);
         }
@@ -346,13 +346,13 @@
         // not symbolic then getLeaf() is a no-op returning ref itself.
         logger.atFinest().log(
             "Include ref %s because its leaf %s is readable", name, ref.getLeaf().getName());
-        resultRefs.put(name, ref);
+        resultRefs.add(ref);
       } else if (isRefsUsersSelf(ref)) {
         // viewMetadata allows to see all account refs, hence refs/users/self should be included as
         // well
         if (viewMetadata) {
           logger.atFinest().log("Include ref %s", REFS_USERS_SELF);
-          resultRefs.put(name, ref);
+          resultRefs.add(ref);
         }
       } else {
         logger.atFinest().log("Filter out ref %s", name);
@@ -370,38 +370,39 @@
    * <p>We exclude symbolic refs because their target will be included and this will suffice for
    * computing reachability.
    */
-  private static Map<String, Ref> getTaggableRefsMap(Repository repo)
-      throws PermissionBackendException {
+  private static List<Ref> getTaggableRefs(Repository repo) throws PermissionBackendException {
     try {
-      return repo.getRefDatabase().getRefs().stream()
+      List<Ref> allRefs = repo.getRefDatabase().getRefs();
+      return allRefs.stream()
           .filter(
               r ->
                   !RefNames.isGerritRef(r.getName())
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
                       && !r.isSymbolic())
-          .collect(toMap(Ref::getName, r -> r));
+          // Don't use the default Java Collections.toList() as that is not size-aware and would
+          // expand an array list as new elements are added. Instead, provide a list that has the
+          // right size. This spares incremental list expansion which is quadratic in complexity.
+          .collect(toCollection(() -> new ArrayList<>(allRefs.size())));
     } catch (IOException e) {
       throw new PermissionBackendException(e);
     }
   }
 
-  private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs)
-      throws PermissionBackendException {
-    if (refs.containsKey(REFS_CONFIG) && !canReadRef(REFS_CONFIG)) {
-      Map<String, Ref> r = new HashMap<>(refs);
-      r.remove(REFS_CONFIG);
-      return r;
+  private List<Ref> fastHideRefsMetaConfig(List<Ref> refs) throws PermissionBackendException {
+    if (!canReadRef(REFS_CONFIG)) {
+      return refs.stream()
+          .filter(r -> !r.getName().equals(REFS_CONFIG))
+          // Don't use the default Java Collections.toList() as that is not size-aware and would
+          // expand an array list as new elements are added. Instead, provide a list that has the
+          // right size. This spares incremental list expansion which is quadratic in complexity.
+          .collect(toCollection(() -> new ArrayList<>(refs.size())));
     }
     return refs;
   }
 
-  private Map<String, Ref> addUsersSelfSymref(Repository repo, Map<String, Ref> refs)
+  private List<Ref> addUsersSelfSymref(Repository repo, List<Ref> refs)
       throws PermissionBackendException {
     if (user.isIdentifiedUser()) {
-      // User self symref is already there
-      if (refs.containsKey(REFS_USERS_SELF)) {
-        return refs;
-      }
       String refName = RefNames.refsUsers(user.getAccountId());
       try {
         Ref r = repo.exactRef(refName);
@@ -411,8 +412,8 @@
         }
 
         SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
-        refs = new HashMap<>(refs);
-        refs.put(s.getName(), s);
+        refs = new ArrayList<>(refs);
+        refs.add(s);
         logger.atFinest().log("Added %s as alias for user ref %s", REFS_USERS_SELF, refName);
       } catch (IOException e) {
         throw new PermissionBackendException(e);
@@ -614,7 +615,7 @@
   @AutoValue
   abstract static class Result {
     /** Subset of the refs passed into the computation that is visible to the user. */
-    abstract Map<String, Ref> visibleRefs();
+    abstract List<Ref> visibleRefs();
 
     /**
      * List of tags where we couldn't figure out visibility in the first pass and need to do an
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 0800d6b..2344781 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend.WithUser;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Collection;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -142,7 +141,7 @@
     }
 
     @Override
-    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+    public Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index a525e29..8ee0fee 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.permissions;
 
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
@@ -41,7 +40,6 @@
 import java.util.EnumSet;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Ref;
@@ -329,33 +327,18 @@
     public abstract BooleanCondition testCond(CoreOrPluginProjectPermission perm);
 
     /**
-     * Filter a map of references by visibility.
-     *
-     * @param refs a map of references to filter.
-     * @param repo an open {@link Repository} handle for this instance's project
-     * @param opts further options for filtering.
-     * @return a partition of the provided refs that are visible to the user that this instance is
-     *     scoped to.
-     * @throws PermissionBackendException if failure consulting backend configuration.
-     */
-    public abstract Map<String, Ref> filter(
-        Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
-        throws PermissionBackendException;
-
-    /**
      * Filter a list of references by visibility.
      *
-     * @param refs a list of references to filter.
+     * @param refs a collection of references to filter.
      * @param repo an open {@link Repository} handle for this instance's project
      * @param opts further options for filtering.
      * @return a partition of the provided refs that are visible to the user that this instance is
      *     scoped to.
      * @throws PermissionBackendException if failure consulting backend configuration.
      */
-    public Map<String, Ref> filter(List<Ref> refs, Repository repo, RefFilterOptions opts)
-        throws PermissionBackendException {
-      return filter(refs.stream().collect(toMap(Ref::getName, r -> r, (a, b) -> b)), repo, opts);
-    }
+    public abstract Collection<Ref> filter(
+        Collection<Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException;
   }
 
   /** Options for filtering refs using {@link ForProject}. */
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index cc3b666..145e0b6 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -404,7 +404,7 @@
     }
 
     @Override
-    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+    public Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       if (refFilter == null) {
         refFilter = refFilterFactory.create(ProjectControl.this);
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 06fe471..378a512 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -440,8 +440,7 @@
     @Override
     public ForChange change(ChangeData cd) {
       try {
-        // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
-        return getProjectControl().controlFor(cd.change()).asForChange(cd);
+        return getProjectControl().controlFor(cd.notes()).asForChange(cd);
       } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
index 8adae52..9e238f8 100644
--- a/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -45,12 +45,9 @@
   }
 
   @Override
-  public Response<PluginInfo> apply(PluginResource resource, Input input) throws RestApiException {
-    try {
-      permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-    } catch (PermissionBackendException e) {
-      throw new RestApiException("Could not check permission", e);
-    }
+  public Response<PluginInfo> apply(PluginResource resource, Input input)
+      throws RestApiException, PermissionBackendException {
+    permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     if (mandatoryPluginsCollection.contains(name)) {
diff --git a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
index 000fb09..ab347e5 100644
--- a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
@@ -21,6 +21,7 @@
 import com.google.inject.Singleton;
 import java.util.concurrent.locks.Lock;
 
+/** In-memory lock for project names. */
 @Singleton
 public class DefaultProjectNameLockManager implements ProjectNameLockManager {
 
diff --git a/java/com/google/gerrit/server/project/FileResource.java b/java/com/google/gerrit/server/project/FileResource.java
index 6e5375a..e8926dc 100644
--- a/java/com/google/gerrit/server/project/FileResource.java
+++ b/java/com/google/gerrit/server/project/FileResource.java
@@ -27,6 +27,11 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
+/**
+ * A file.
+ *
+ * <p>This is in the project package because it is accessed through the project/branch/file path.
+ */
 public class FileResource implements RestResource {
   public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
       new TypeLiteral<RestView<FileResource>>() {};
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index fe59012..ba7dc95 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -28,6 +28,12 @@
 import java.util.Map;
 import java.util.Set;
 
+/**
+ * File format for group name aliases.
+ *
+ * <p>Project configuration must use aliases for groups used in the permission section. The
+ * aliases/group mapping is stored in a file "groups", (de)serialized with this class.
+ */
 public class GroupList extends TabFile {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
new file mode 100644
index 0000000..2ecd8c2
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static java.util.stream.Collectors.toMap;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+
+public class LabelDefinitionJson {
+  public static LabelDefinitionInfo format(Project.NameKey projectName, LabelType labelType) {
+    LabelDefinitionInfo label = new LabelDefinitionInfo();
+    label.name = labelType.getName();
+    label.projectName = projectName.get();
+    label.function = labelType.getFunction().getFunctionName();
+    label.values =
+        labelType.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
+    label.defaultValue = labelType.getDefaultValue();
+    label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
+    label.canOverride = toBoolean(labelType.canOverride());
+    label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
+    label.copyMinScore = toBoolean(labelType.isCopyMinScore());
+    label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
+    label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
+    label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
+    label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
+    label.copyAllScoresOnMergeFirstParentUpdate =
+        toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
+    label.allowPostSubmit = toBoolean(labelType.allowPostSubmit());
+    label.ignoreSelfApproval = toBoolean(labelType.ignoreSelfApproval());
+    return label;
+  }
+
+  private static Boolean toBoolean(boolean v) {
+    return v ? v : null;
+  }
+
+  private LabelDefinitionJson() {}
+}
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
new file mode 100644
index 0000000..a7a2f07
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class LabelResource implements RestResource {
+  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND =
+      new TypeLiteral<RestView<LabelResource>>() {};
+
+  private final ProjectResource project;
+  private final LabelType labelType;
+
+  public LabelResource(ProjectResource project, LabelType labelType) {
+    this.project = project;
+    this.labelType = labelType;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public LabelType getLabelType() {
+    return labelType;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 44d9d98..fa877af 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.common.data.Permission.isPermission;
 import static com.google.gerrit.entities.Project.DEFAULT_SUBMIT_TYPE;
 import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
@@ -112,10 +113,10 @@
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
 
-  private static final String KEY_MATCH = "match";
+  public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
-  private static final String KEY_LINK = "link";
-  private static final String KEY_ENABLED = "enabled";
+  public static final String KEY_LINK = "link";
+  public static final String KEY_ENABLED = "enabled";
 
   public static final String PROJECT_CONFIG = "project.config";
 
@@ -291,6 +292,11 @@
     commentLinkSections.put(commentLink.name, commentLink);
   }
 
+  public void removeCommentLinkSection(String name) {
+    requireNonNull(name);
+    requireNonNull(commentLinkSections.remove(name));
+  }
+
   private ProjectConfig(Project.NameKey projectName, @Nullable StoredConfig baseConfig) {
     this.projectName = projectName;
     this.baseConfig = baseConfig;
@@ -917,9 +923,18 @@
       lowerNames.put(lower, name);
 
       List<LabelValue> values = new ArrayList<>();
+      Set<Short> allValues = new HashSet<>();
       for (String value : rc.getStringList(LABEL, name, KEY_VALUE)) {
         try {
-          values.add(parseLabelValue(value));
+          LabelValue labelValue = parseLabelValue(value);
+          if (allValues.add(labelValue.getValue())) {
+            values.add(labelValue);
+          } else {
+            error(
+                new ValidationError(
+                    PROJECT_CONFIG,
+                    String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name)));
+          }
         } catch (IllegalArgumentException notValue) {
           error(
               new ValidationError(
@@ -1488,6 +1503,8 @@
       List<String> refPatterns = label.getRefPatterns();
       if (refPatterns != null && !refPatterns.isEmpty()) {
         rc.setStringList(LABEL, name, KEY_BRANCH, refPatterns);
+      } else {
+        rc.unset(LABEL, name, KEY_BRANCH);
       }
     }
 
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index c9eb73e..c4c466e 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
@@ -54,6 +55,12 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
+/**
+ * Business logic for creating projects.
+ *
+ * <p>This creates the repository, the underlying configuration in {@code refs/meta/config} and
+ * initializes a first commit if necessary.
+ */
 public class ProjectCreator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -201,10 +208,11 @@
             referenceUpdated.fire(
                 project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
             break;
+          case LOCK_FAILURE:
+            throw new LockFailureException(String.format("Failed to create ref \"%s\"", ref), ru);
           case FAST_FORWARD:
           case FORCED:
           case IO_FAILURE:
-          case LOCK_FAILURE:
           case NOT_ATTEMPTED:
           case NO_CHANGE:
           case REJECTED:
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index cd67fbd..f2254d6 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -31,6 +31,7 @@
 import com.google.inject.Singleton;
 import java.util.HashMap;
 
+/** Collection of routines to populate {@link ProjectInfo}. */
 @Singleton
 public class ProjectJson {
 
diff --git a/java/com/google/gerrit/server/project/ProjectNameLockManager.java b/java/com/google/gerrit/server/project/ProjectNameLockManager.java
index 72036a7..f67dd04 100644
--- a/java/com/google/gerrit/server/project/ProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/ProjectNameLockManager.java
@@ -17,6 +17,14 @@
 import com.google.gerrit.entities.Project;
 import java.util.concurrent.locks.Lock;
 
+/**
+ * A per-repo lock mechanism.
+ *
+ * <p>This ensures that project creation (repo creation, config creation, first commit) is atomic,
+ * and can be used to separate creation and deletion in the delete-project plugin.
+ *
+ * <p>This is an interface because distributed setup may need something beyond an in-memory lock.
+ */
 public interface ProjectNameLockManager {
   public Lock getLock(Project.NameKey name);
 }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index f6aba34..110beaf 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -48,13 +48,10 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIdPredicate;
 import com.google.gerrit.server.query.change.CommitPredicate;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.query.change.RefPredicate;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -76,7 +73,6 @@
 
   private final GitRepositoryManager repoManager;
   private final RetryHelper retryHelper;
-  private final Provider<InternalChangeQuery> changeQueryProvider;
   private final ChangeJson.Factory changeJsonFactory;
   private final IndexConfig indexConfig;
 
@@ -84,12 +80,10 @@
   ProjectsConsistencyChecker(
       GitRepositoryManager repoManager,
       RetryHelper retryHelper,
-      Provider<InternalChangeQuery> changeQueryProvider,
       ChangeJson.Factory changeJsonFactory,
       IndexConfig indexConfig) {
     this.repoManager = repoManager;
     this.retryHelper = retryHelper;
-    this.changeQueryProvider = changeQueryProvider;
     this.changeJsonFactory = changeJsonFactory;
     this.indexConfig = indexConfig;
   }
@@ -264,16 +258,13 @@
 
     try {
       List<ChangeData> queryResult =
-          retryHelper.execute(
-              ActionType.INDEX_QUERY,
-              () -> {
-                // Execute the query.
-                return changeQueryProvider
-                    .get()
-                    .setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
-                    .query(and(basePredicate, or(predicates)));
-              },
-              StorageException.class::isInstance);
+          retryHelper
+              .changeIndexQuery(
+                  "projectsConsistencyCheckerQueryChanges",
+                  q ->
+                      q.setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+                          .query(and(basePredicate, or(predicates))))
+              .call();
 
       // Result for this query that we want to return to the client.
       List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
@@ -282,32 +273,34 @@
         // Skip changes that we have already processed, either by this query or by
         // earlier queries.
         if (seenChanges.add(autoCloseableChange.getId())) {
-          retryHelper.execute(
-              ActionType.CHANGE_UPDATE,
-              () -> {
-                // Auto-close by change
-                if (changeIdToMergedSha1.containsKey(autoCloseableChange.change().getKey())) {
-                  autoCloseableChangesByBranch.add(
-                      changeJson(
-                              fix, changeIdToMergedSha1.get(autoCloseableChange.change().getKey()))
-                          .format(autoCloseableChange));
-                  return null;
-                }
+          retryHelper
+              .changeUpdate(
+                  "projectsConsistencyCheckerAutoCloseChanges",
+                  () -> {
+                    // Auto-close by change
+                    if (changeIdToMergedSha1.containsKey(autoCloseableChange.change().getKey())) {
+                      autoCloseableChangesByBranch.add(
+                          changeJson(
+                                  fix,
+                                  changeIdToMergedSha1.get(autoCloseableChange.change().getKey()))
+                              .format(autoCloseableChange));
+                      return null;
+                    }
 
-                // Auto-close by commit
-                for (ObjectId patchSetSha1 :
-                    autoCloseableChange.patchSets().stream()
-                        .map(PatchSet::commitId)
-                        .collect(toSet())) {
-                  if (mergedSha1s.contains(patchSetSha1)) {
-                    autoCloseableChangesByBranch.add(
-                        changeJson(fix, patchSetSha1).format(autoCloseableChange));
-                    break;
-                  }
-                }
-                return null;
-              },
-              StorageException.class::isInstance);
+                    // Auto-close by commit
+                    for (ObjectId patchSetSha1 :
+                        autoCloseableChange.patchSets().stream()
+                            .map(PatchSet::commitId)
+                            .collect(toSet())) {
+                      if (mergedSha1s.contains(patchSetSha1)) {
+                        autoCloseableChangesByBranch.add(
+                            changeJson(fix, patchSetSha1).format(autoCloseableChange));
+                        break;
+                      }
+                    }
+                    return null;
+                  })
+              .call();
         }
       }
 
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 4ea5d11..57e9a7e 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -17,14 +17,17 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.change.IncludedInResolver;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -53,12 +56,20 @@
   public boolean fromRefs(
       Project.NameKey project, Repository repo, RevCommit commit, List<Ref> refs) {
     try (RevWalk rw = new RevWalk(repo)) {
-      Map<String, Ref> filtered =
+      Collection<Ref> filtered =
           permissionBackend
               .currentUser()
               .project(project)
               .filter(refs, repo, RefFilterOptions.defaults());
-      return IncludedInResolver.includedInAny(repo, rw, commit, filtered.values());
+
+      // The filtering above already produces a voluminous trace. To separate the permission check
+      // from the reachability check, do the trace here:
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "IncludedInResolver.includedInAny",
+              Metadata.builder().projectName(project.get()).resourceCount(refs.size()).build())) {
+        return IncludedInResolver.includedInAny(repo, rw, commit, filtered);
+      }
     } catch (IOException | PermissionBackendException e) {
       logger.atSevere().withCause(e).log(
           "Cannot verify permissions to commit object %s in repository %s", commit.name(), project);
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 1eed7ea..e4da946 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import java.util.List;
 
+/** Utility class to create predicates for account index queries. */
 public class AccountPredicates {
   public static boolean hasActive(Predicate<AccountState> p) {
     return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
@@ -130,6 +131,7 @@
     return new CanSeeChangePredicate(args.permissionBackend, changeNotes);
   }
 
+  /** Predicate that is mapped to a field in the account index. */
   static class AccountPredicate extends IndexPredicate<AccountState>
       implements Matchable<AccountState> {
     AccountPredicate(FieldDef<AccountState, ?> def, String value) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index c6beac4..883f550 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -396,12 +395,7 @@
         return Optional.empty();
       }
 
-      ObjectId id = ps.commitId();
-      Whitespace ws = Whitespace.IGNORE_NONE;
-      PatchListKey pk =
-          parentCount > 1
-              ? PatchListKey.againstParentNum(1, id, ws)
-              : PatchListKey.againstDefaultBase(id, ws);
+      PatchListKey pk = PatchListKey.againstBase(ps.commitId(), parentCount);
       DiffSummaryKey key = DiffSummaryKey.fromPatchListKey(pk);
       try {
         diffSummary = Optional.of(patchListCache.getDiffSummary(key, c.getProject()));
@@ -598,7 +592,11 @@
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
     } catch (IOException e) {
-      throw new StorageException(e);
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
     }
     return true;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 7428e3a..a176a58 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 
+/** Predicate that is mapped to a field in the change index. */
 public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
     implements Matchable<ChangeData> {
   /**
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d2fc77d..327c21f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -25,6 +25,7 @@
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
@@ -33,6 +34,7 @@
 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.entities.RefNames;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
@@ -61,8 +63,11 @@
 import com.google.gerrit.server.account.VersionedAccountDestinations;
 import com.google.gerrit.server.account.VersionedAccountQueries;
 import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.OperatorAliasConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -93,10 +98,13 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /** Parses a query string meant to be applied to change objects. */
 public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuilder> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface ChangeOperatorFactory extends OperatorFactory<ChangeData, ChangeQueryBuilder> {}
 
   /**
@@ -183,6 +191,9 @@
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
   public static final String FIELD_REVERTOF = "revertof";
+  public static final String FIELD_CHERRY_PICK_OF = "cherrypickof";
+  public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
+  public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
 
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
@@ -219,6 +230,8 @@
     final SubmitDryRun submitDryRun;
     final GroupMembers groupMembers;
     final Provider<AnonymousUser> anonymousUserProvider;
+    final OperatorAliasConfig operatorAliasConfig;
+    final boolean indexMergeable;
 
     private final Provider<CurrentUser> self;
 
@@ -250,7 +263,9 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         GroupMembers groupMembers,
-        Provider<AnonymousUser> anonymousUserProvider) {
+        Provider<AnonymousUser> anonymousUserProvider,
+        OperatorAliasConfig operatorAliasConfig,
+        @GerritServerConfig Config gerritConfig) {
       this(
           queryProvider,
           rewriter,
@@ -277,7 +292,9 @@
           starredChangesUtil,
           accountCache,
           groupMembers,
-          anonymousUserProvider);
+          anonymousUserProvider,
+          operatorAliasConfig,
+          MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex());
     }
 
     private Arguments(
@@ -306,7 +323,9 @@
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
         GroupMembers groupMembers,
-        Provider<AnonymousUser> anonymousUserProvider) {
+        Provider<AnonymousUser> anonymousUserProvider,
+        OperatorAliasConfig operatorAliasConfig,
+        boolean indexMergeable) {
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
       this.opFactories = opFactories;
@@ -333,6 +352,8 @@
       this.hasOperands = hasOperands;
       this.groupMembers = groupMembers;
       this.anonymousUserProvider = anonymousUserProvider;
+      this.operatorAliasConfig = operatorAliasConfig;
+      this.indexMergeable = indexMergeable;
     }
 
     Arguments asUser(CurrentUser otherUser) {
@@ -362,7 +383,9 @@
           starredChangesUtil,
           accountCache,
           groupMembers,
-          anonymousUserProvider);
+          anonymousUserProvider,
+          operatorAliasConfig,
+          indexMergeable);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -407,6 +430,7 @@
   @Inject
   ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
+    setupAliases();
   }
 
   @VisibleForTesting
@@ -415,6 +439,10 @@
     this.args = args;
   }
 
+  private void setupAliases() {
+    setOperatorAliases(args.operatorAliasConfig.getChangeQueryOperatorAliases());
+  }
+
   public Arguments getArgs() {
     return args;
   }
@@ -558,6 +586,9 @@
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
+      if (!args.indexMergeable) {
+        throw new QueryParseException("'is:mergeable' operator is not supported by server");
+      }
       return new BooleanPredicate(ChangeField.MERGEABLE);
     }
 
@@ -1233,6 +1264,39 @@
     throw new QueryParseException("'revertof' operator is not supported by change index version");
   }
 
+  @Operator
+  public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
+      return new SubmissionIdPredicate(value);
+    }
+    throw new QueryParseException(
+        "'submissionid' operator is not supported by change index version");
+  }
+
+  @Operator
+  public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_CHANGE)
+        && args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_PATCHSET)) {
+      if (Ints.tryParse(value) != null) {
+        return new CherryPickOfChangePredicate(value);
+      }
+      try {
+        PatchSet.Id patchSetId = PatchSet.Id.parse(value);
+        return Predicate.and(
+            new CherryPickOfChangePredicate(patchSetId.changeId().toString()),
+            new CherryPickOfPatchSetPredicate(patchSetId.getId()));
+      } catch (IllegalArgumentException e) {
+        throw new QueryParseException(
+            "'"
+                + value
+                + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
+            e);
+      }
+    }
+    throw new QueryParseException(
+        "'cherrypickof' operator is not supported by change index version");
+  }
+
   @Override
   protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
     if (query.startsWith("refs/")) {
@@ -1362,7 +1426,11 @@
 
   private List<Change> parseChange(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
-      return asChanges(args.queryProvider.get().byLegacyChangeId(Change.Id.parse(value)));
+      Optional<Change.Id> id = Change.Id.tryParse(value);
+      if (!id.isPresent()) {
+        throw error("Invalid change id " + value);
+      }
+      return asChanges(args.queryProvider.get().byLegacyChangeId(id.get()));
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
       List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
       if (changes.isEmpty()) {
@@ -1405,8 +1473,14 @@
                 accounts.stream()
                     .map(id -> ReviewerPredicate.forState(id, state))
                     .collect(toList()));
+      } else {
+        logger.atFine().log(
+            "Skipping reviewer predicate for %s in default field query"
+                + " because the number of matched accounts (%d) exceeds the limit of %d",
+            who, accounts.size(), MAX_ACCOUNTS_PER_DEFAULT_FIELD);
       }
     } catch (QueryParseException e) {
+      logger.atFine().log("Parsing %s as account failed: %s", who, e.getMessage());
       // Propagate this exception only if we can't use 'who' to query by email
       if (reviewerByEmailPredicate == null) {
         throw e;
diff --git a/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java b/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java
new file mode 100644
index 0000000..d452017
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class CherryPickOfChangePredicate extends ChangeIndexPredicate {
+  public CherryPickOfChangePredicate(String cherryPickOfChange) {
+    super(ChangeField.CHERRY_PICK_OF_CHANGE, cherryPickOfChange);
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    if (cd.change().getCherryPickOf() == null) {
+      return false;
+    }
+    return Integer.toString(cd.change().getCherryPickOf().changeId().get()).equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java b/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java
new file mode 100644
index 0000000..888f45d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class CherryPickOfPatchSetPredicate extends ChangeIndexPredicate {
+  public CherryPickOfPatchSetPredicate(String cherryPickOfPatchSet) {
+    super(ChangeField.CHERRY_PICK_OF_PATCHSET, cherryPickOfPatchSet);
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    if (cd.change().getCherryPickOf() == null) {
+      return false;
+    }
+    return cd.change().getCherryPickOf().getId().equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 17a7000..5231c5a 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.index.group.GroupField;
 import java.util.Locale;
 
+/** Utility class to create predicates for group index queries. */
 public class GroupPredicates {
   public static Predicate<InternalGroup> id(AccountGroup.Id groupId) {
     return new GroupPredicate(GroupField.ID, groupId.toString());
@@ -63,6 +64,7 @@
     return new GroupPredicate(GroupField.SUBGROUP, subgroupUuid.get());
   }
 
+  /** Predicate that is mapped to a field in the group index. */
   static class GroupPredicate extends IndexPredicate<InternalGroup> {
     GroupPredicate(FieldDef<InternalGroup, ?> def, String value) {
       super(def, value);
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index 4e56fac..8b4048f 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.index.query.Predicate;
 import java.util.Locale;
 
+/** Utility class to create predicates for project index queries. */
 public class ProjectPredicates {
   public static Predicate<ProjectData> name(Project.NameKey nameKey) {
     return new ProjectPredicate(ProjectField.NAME, nameKey.get());
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index fd341e9..fca497e 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -17,11 +17,9 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/jgit",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
-        "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/util/time",
@@ -34,7 +32,6 @@
         "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:lang",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/restapi/access/AccessCollection.java b/java/com/google/gerrit/server/restapi/access/AccessCollection.java
index 8ae2ce7..d8832a2 100644
--- a/java/com/google/gerrit/server/restapi/access/AccessCollection.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessCollection.java
@@ -24,6 +24,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+/** REST collection that serves requests to {@code /access/}. */
 @Singleton
 public class AccessCollection implements RestCollection<TopLevelResource, AccessResource> {
   private final Provider<ListAccess> list;
diff --git a/java/com/google/gerrit/server/restapi/access/AccessResource.java b/java/com/google/gerrit/server/restapi/access/AccessResource.java
index 915165b..4847da4 100644
--- a/java/com/google/gerrit/server/restapi/access/AccessResource.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessResource.java
@@ -18,6 +18,13 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
 
+/**
+ * REST resource that represents members in {@link AccessCollection}.
+ *
+ * <p>{@link AccessCollection} doesn't support accessing single members, hence this class only
+ * defines the {@link TypeLiteral} for the resource kind that is needed for binding the REST
+ * collection.
+ */
 public class AccessResource implements RestResource {
   public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND =
       new TypeLiteral<RestView<AccessResource>>() {};
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index 2520821..1e1bade 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -27,6 +27,11 @@
 import java.util.TreeMap;
 import org.kohsuke.args4j.Option;
 
+/**
+ * REST endpoint to list members of the {@link AccessCollection}.
+ *
+ * <p>This REST endpoint handles {@code GET /access/} requests.
+ */
 public class ListAccess implements RestReadView<TopLevelResource> {
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/access/Module.java b/java/com/google/gerrit/server/restapi/access/Module.java
index 7da2e26b..3a4955d 100644
--- a/java/com/google/gerrit/server/restapi/access/Module.java
+++ b/java/com/google/gerrit/server/restapi/access/Module.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
 
+/** Guice module that binds all REST endpoints for {@code /access/}. */
 public class Module extends RestApiModule {
   @Override
   protected void configure() {
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index 1fcf0bd..f18cc67 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -44,6 +44,11 @@
 import java.io.InputStream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to add an SSH key for an account.
+ *
+ * <p>This REST endpoint handles {@code POST /accounts/<account-identifier>/sshkeys/} requests.
+ */
 @Singleton
 public class AddSshKey
     implements RestCollectionModifyView<AccountResource, AccountResource.SshKey, SshKeyInput> {
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index c110194..907dd18 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -62,6 +62,13 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint for creating a new account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>} requests if the
+ * specified account doesn't exist yet. If it already exists, the request is handled by {@link
+ * PutAccount}.
+ */
 @RequiresCapability(GlobalCapability.CREATE_ACCOUNT)
 @Singleton
 public class CreateAccount
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index ae45b68..fee5eab 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -47,6 +48,26 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint for registering a new email address for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT
+ * /accounts/<account-identifier>/emails/<email-identifier>} requests if the specified email doesn't
+ * exist for the account yet. If it already exists, the request is handled by {@link PutEmail}.
+ *
+ * <p>Whether an email address can be registered for the account depends on whether the used {@link
+ * Realm} supports this.
+ *
+ * <p>When a new email address is registered an email with a confirmation link is sent to that
+ * address. Only when the receiver confirms the email by clicking on the confirmation link, the
+ * email address is added to the account (see {@link
+ * com.google.gerrit.server.restapi.config.ConfirmEmail}). Confirming an email address for an
+ * account creates an external ID that links the email address to the account. An email address can
+ * only be added to an account if it is not assigned to any other account yet.
+ *
+ * <p>In some cases it is allowed to skip the email confirmation and add the email directly (calling
+ * user has 'Modify Account' capability or server is running in dev mode).
+ */
 @Singleton
 public class CreateEmail
     implements RestCollectionCreateView<AccountResource, AccountResource.Email, EmailInput> {
@@ -101,6 +122,7 @@
   }
 
   /** To be used from plugins that want to create emails without permission checks. */
+  @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public EmailInfo apply(IdentifiedUser user, IdString id, EmailInput input)
       throws RestApiException, EmailException, MethodNotAllowedException, IOException,
           ConfigInvalidException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteActive.java b/java/com/google/gerrit/server/restapi/account/DeleteActive.java
index ffd7893..7295370 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteActive.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteActive.java
@@ -30,6 +30,15 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to mark an account as inactive.
+ *
+ * <p>This REST endpoint handles {@code DELETE /accounts/<account-identifier>/active} requests.
+ *
+ * <p>Inactive accounts cannot login into Gerrit.
+ *
+ * <p>Marking an account as active is handled by {@link PutActive}.
+ */
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class DeleteActive implements RestModifyView<AccountResource, Input> {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index a6fbb10..4b505c6 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
-import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -190,9 +189,7 @@
       if (dirty) {
         result = new DeletedDraftCommentInfo();
         result.change =
-            changeJsonFactory
-                .create(ListChangesOption.SKIP_MERGEABLE)
-                .format(changeDataFactory.create(ctx.getNotes()));
+            changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
         result.deleted = comments.build();
       }
       return dirty;
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
index 82b445f..442b6a4 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
@@ -44,6 +44,12 @@
 import java.util.function.Function;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to delete external IDs from an account.
+ *
+ * <p>This REST endpoint handles {@code POST /accounts/<account-identifier>/external.ids:delete}
+ * requests.
+ */
 @Singleton
 public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index b470be8..f73f00a 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -36,6 +36,12 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
+/**
+ * REST endpoint to delete an SSH key of an account.
+ *
+ * <p>This REST endpoint handles {@code DELETE
+ * /accounts/<account-identifier>/sshkeys/<ssh-key-identifier>} requests.
+ */
 @Singleton
 public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
index 9f38b97..e070522 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteWatchedProjects.java
@@ -39,6 +39,12 @@
 import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to delete project watches from an account.
+ *
+ * <p>This REST endpoint handles {@code POST /accounts/<account-identifier>/watched.projects:delete}
+ * requests.
+ */
 @Singleton
 public class DeleteWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
diff --git a/java/com/google/gerrit/server/restapi/account/GetAccount.java b/java/com/google/gerrit/server/restapi/account/GetAccount.java
index 898b0bb..2b6a9e6 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAccount.java
@@ -23,6 +23,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>} requests.
+ *
+ * <p>In the response only a subset of fields is populated (see {@link
+ * AccountLoader#DETAILED_OPTIONS}). In contrast to this {@link GetDetail} populates all fields in
+ * the response.
+ */
 @Singleton
 public class GetAccount implements RestReadView<AccountResource> {
   private final AccountLoader.Factory infoFactory;
diff --git a/java/com/google/gerrit/server/restapi/account/GetActive.java b/java/com/google/gerrit/server/restapi/account/GetActive.java
index 66493f8..38c740c 100644
--- a/java/com/google/gerrit/server/restapi/account/GetActive.java
+++ b/java/com/google/gerrit/server/restapi/account/GetActive.java
@@ -19,6 +19,13 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get the active state of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/active} requests.
+ *
+ * <p>Only active accounts can login into Gerrit.
+ */
 @Singleton
 public class GetActive implements RestReadView<AccountResource> {
   @Override
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index 5feca66..aeaeb1c 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -29,6 +29,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.config.AgreementJson;
@@ -40,6 +42,14 @@
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * REST endpoint to get all contributor agreements that have been signed by an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/agreements} requests.
+ *
+ * <p>Contributor agreements are only available if contributor agreements have been enabled in
+ * {@code gerrit.config} (see {@code auth.contributorAgreements}).
+ */
 @Singleton
 public class GetAgreements implements RestReadView<AccountResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -48,17 +58,20 @@
   private final ProjectCache projectCache;
   private final AgreementJson agreementJson;
   private final boolean agreementsEnabled;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   GetAgreements(
       Provider<CurrentUser> self,
       ProjectCache projectCache,
       AgreementJson agreementJson,
+      PermissionBackend permissionBackend,
       @GerritServerConfig Config config) {
     this.self = self;
     this.projectCache = projectCache;
     this.agreementJson = agreementJson;
     this.agreementsEnabled = config.getBoolean("auth", "contributorAgreements", false);
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -74,7 +87,11 @@
 
     IdentifiedUser user = self.get().asIdentifiedUser();
     if (user != resource.getUser()) {
-      throw new AuthException("not allowed to get contributor agreements");
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      } catch (AuthException e) {
+        throw new AuthException("not allowed to get contributor agreements", e);
+      }
     }
 
     List<AgreementInfo> results = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatar.java b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
index 3c1752d..5256d68 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatar.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
@@ -26,6 +26,13 @@
 import java.util.concurrent.TimeUnit;
 import org.kohsuke.args4j.Option;
 
+/**
+ * REST endpoint to get the avatar image of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/avatar} requests.
+ *
+ * <p>Avatar images are only available if an {@link AvatarProvider} plugin is installed.
+ */
 public class GetAvatar implements RestReadView<AccountResource> {
   private final DynamicItem<AvatarProvider> avatarProvider;
 
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
index e97e0a0..a26df64 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatarChangeUrl.java
@@ -24,6 +24,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get the URL for changing the avatar image of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/avatar.change.url}
+ * requests.
+ *
+ * <p>Avatar images are only available if an {@link AvatarProvider} plugin is installed. Not all
+ * avatar plugins provide a URL for changing avatar images.
+ */
 @Singleton
 public class GetAvatarChangeUrl implements RestReadView<AccountResource> {
   private final DynamicItem<AvatarProvider> avatarProvider;
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index fa9ab18..f3d9557 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -49,6 +49,11 @@
 import java.util.Set;
 import org.kohsuke.args4j.Option;
 
+/**
+ * REST endpoint to list the global capabilities that are assigned to an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/capabilities/} requests.
+ */
 public class GetCapabilities implements RestReadView<AccountResource> {
   @Option(name = "-q", metaVar = "CAP", usage = "Capability to inspect")
   void addQuery(String name) {
@@ -159,6 +164,12 @@
     }
   }
 
+  /**
+   * REST endpoint to check if a global capability is assigned to an account.
+   *
+   * <p>This REST endpoint handles {@code GET
+   * /accounts/<account-identifier>/capabilities/<capability-identifier>} requests.
+   */
   @Singleton
   public static class CheckOne implements RestReadView<AccountResource.Capability> {
     private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index b19559e..1091599 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -27,6 +27,14 @@
 import java.util.Collections;
 import java.util.EnumSet;
 
+/**
+ * REST endpoint to get details of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/detail} requests.
+ *
+ * <p>In the response all fields are populated. In contrast to this {@link GetAccount} populates
+ * only a subset of the fields in the response.
+ */
 @Singleton
 public class GetDetail implements RestReadView<AccountResource> {
   private final InternalAccountDirectory directory;
diff --git a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
index 670ef3b..53ce94b 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDiffPreferences.java
@@ -34,6 +34,18 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to get the diff preferences of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/preferences.diff}
+ * requests.
+ *
+ * <p>General preferences can be retrieved by {@link GetPreferences} and edit preferences can be
+ * retrieved by {@link GetEditPreferences}.
+ *
+ * <p>Default diff preferences that apply for all accounts can be retrieved by {@link
+ * com.google.gerrit.server.restapi.config.GetDiffPreferences}.
+ */
 @Singleton
 public class GetDiffPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
index 409209e..0f117eb 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEditPreferences.java
@@ -34,6 +34,18 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to get the edit preferences of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/preferences.edit}
+ * requests.
+ *
+ * <p>General preferences can be retrieved by {@link GetPreferences} and diff preferences can be
+ * retrieved by {@link GetDiffPreferences}.
+ *
+ * <p>Default edit preferences that apply for all accounts can be retrieved by {@link
+ * com.google.gerrit.server.restapi.config.GetEditPreferences}.
+ */
 @Singleton
 public class GetEditPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmail.java b/java/com/google/gerrit/server/restapi/account/GetEmail.java
index afcdac2..4a71858 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmail.java
@@ -21,6 +21,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get an email of an account.
+ *
+ * <p>This REST endpoint handles {@code GET
+ * /accounts/<account-identifier>/emails/<email-identifier>} requests.
+ */
 @Singleton
 public class GetEmail implements RestReadView<AccountResource.Email> {
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 9db9f05..4d70eb9 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -32,6 +32,11 @@
 import java.util.List;
 import java.util.Objects;
 
+/**
+ * REST endpoint to list the emails of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/emails/} requests.
+ */
 @Singleton
 public class GetEmails implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index 0e52af2..c5b4454 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -39,6 +39,11 @@
 import java.util.List;
 import java.util.Optional;
 
+/**
+ * REST endpoint to get the external IDs of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/external.ids} requests.
+ */
 @Singleton
 public class GetExternalIds implements RestReadView<AccountResource> {
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/GetGroups.java b/java/com/google/gerrit/server/restapi/account/GetGroups.java
index 22492a7..47fe96f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetGroups.java
+++ b/java/com/google/gerrit/server/restapi/account/GetGroups.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
@@ -31,8 +32,19 @@
 import java.util.List;
 import java.util.Set;
 
+/**
+ * REST endpoint to get all known groups of an account (groups that contain the account as member).
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/groups} requests.
+ *
+ * <p>The response may not contain all groups of the account as not all groups may be known (see
+ * {@link com.google.gerrit.server.account.GroupMembership#getKnownGroups()}). In addition groups
+ * that are not visible to the calling user are filtered out.
+ */
 @Singleton
 public class GetGroups implements RestReadView<AccountResource> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final GroupControl.Factory groupControlFactory;
   private final GroupJson json;
 
@@ -54,11 +66,22 @@
       try {
         ctl = groupControlFactory.controlFor(uuid);
       } catch (NoSuchGroupException e) {
+        logger.atFine().log("skipping non-existing group %s", uuid);
         continue;
       }
-      if (ctl.isVisible() && ctl.canSeeMember(userId)) {
-        visibleGroups.add(json.format(ctl.getGroup()));
+
+      if (!ctl.isVisible()) {
+        logger.atFine().log("skipping non-visible group %s", uuid);
+        continue;
       }
+
+      if (!ctl.canSeeMember(userId)) {
+        logger.atFine().log(
+            "skipping group %s because member %d cannot be seen", uuid, userId.get());
+        continue;
+      }
+
+      visibleGroups.add(json.format(ctl.getGroup()));
     }
     return Response.ok(visibleGroups);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/GetName.java b/java/com/google/gerrit/server/restapi/account/GetName.java
index ca33887..0e73058 100644
--- a/java/com/google/gerrit/server/restapi/account/GetName.java
+++ b/java/com/google/gerrit/server/restapi/account/GetName.java
@@ -20,6 +20,11 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get the full name of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/name} requests.
+ */
 @Singleton
 public class GetName implements RestReadView<AccountResource> {
   @Override
diff --git a/java/com/google/gerrit/server/restapi/account/GetPreferences.java b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
index d4d73c5..bfc6852 100644
--- a/java/com/google/gerrit/server/restapi/account/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/GetPreferences.java
@@ -35,6 +35,17 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get the general preferences of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/preferences} requests.
+ *
+ * <p>Diff preferences can be retrieved by {@link GetDiffPreferences} and edit preferences can be
+ * retrieved by {@link GetEditPreferences}.
+ *
+ * <p>Default general preferences that apply for all accounts can be retrieved by {@link
+ * com.google.gerrit.server.restapi.config.GetPreferences}.
+ */
 @Singleton
 public class GetPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKey.java b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
index 58b5d12..70c1327 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKey.java
@@ -21,6 +21,12 @@
 import com.google.gerrit.server.account.AccountResource.SshKey;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get an SSH key of an account.
+ *
+ * <p>This REST endpoint handles {@code GET
+ * /accounts/<account-identifier>/sshkeys/<ssh-key-identifier>} requests.
+ */
 @Singleton
 public class GetSshKey implements RestReadView<AccountResource.SshKey> {
 
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index 0ca9b9e..6df6c3c 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -36,6 +36,11 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
+/**
+ * REST endpoint to list the SSH keys of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/sshkeys/} requests.
+ */
 @Singleton
 public class GetSshKeys implements RestReadView<AccountResource> {
 
diff --git a/java/com/google/gerrit/server/restapi/account/GetStatus.java b/java/com/google/gerrit/server/restapi/account/GetStatus.java
index 447ad76..d29cdcd 100644
--- a/java/com/google/gerrit/server/restapi/account/GetStatus.java
+++ b/java/com/google/gerrit/server/restapi/account/GetStatus.java
@@ -20,6 +20,14 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get the status of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/status} requests.
+ *
+ * <p>The account status is a free-form text that a user can set for the own account (e.g. the 'OOO'
+ * string is often used to signal that the user is out-of-office).
+ */
 @Singleton
 public class GetStatus implements RestReadView<AccountResource> {
   @Override
diff --git a/java/com/google/gerrit/server/restapi/account/GetUsername.java b/java/com/google/gerrit/server/restapi/account/GetUsername.java
index 7e58f94..c582039 100644
--- a/java/com/google/gerrit/server/restapi/account/GetUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/GetUsername.java
@@ -22,6 +22,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint to get the username of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/username} requests.
+ */
 @Singleton
 public class GetUsername implements RestReadView<AccountResource> {
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 353e3f6..beb5e8f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -41,6 +41,12 @@
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to get the project watches of an account.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/<account-identifier>/watched.projects}
+ * requests.
+ */
 @Singleton
 public class GetWatchedProjects implements RestReadView<AccountResource> {
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/Index.java b/java/com/google/gerrit/server/restapi/account/Index.java
index 6ddfc0f4..14c9f40 100644
--- a/java/com/google/gerrit/server/restapi/account/Index.java
+++ b/java/com/google/gerrit/server/restapi/account/Index.java
@@ -29,6 +29,14 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 
+/**
+ * REST endpoint to (re)index an account.
+ *
+ * <p>This REST endpoint handles {@code POST /accounts/<account-identifier>/index} requests.
+ *
+ * <p>If the document of an account in the account index is stale, this REST endpoint can be used to
+ * update the index.
+ */
 @Singleton
 public class Index implements RestModifyView<AccountResource, Input> {
 
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 5236174..b2859e6 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -41,6 +41,12 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set project watches for an account.
+ *
+ * <p>This REST endpoint handles {@code POST /accounts/<account-identifier>/watched.projects}
+ * requests.
+ */
 @Singleton
 public class PostWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
diff --git a/java/com/google/gerrit/server/restapi/account/PutAccount.java b/java/com/google/gerrit/server/restapi/account/PutAccount.java
index 4c84c19..780002c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAccount.java
@@ -22,6 +22,23 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint for updating an existing account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>} requests if the
+ * specified account already exists. If it doesn't exist yet, the request is handled by {@link
+ * CreateAccount}.
+ *
+ * <p>We do not support account updates via this path, hence this REST endpoint always throws a
+ * {@link ResourceConflictException} which results in a {@code 409 Conflict} response. Account
+ * properties can only be updated via the dedicated REST endpoints that serve {@code PUT} requests
+ * on {@code /accounts/<account-identifier>/<account-view>}.
+ *
+ * <p>This REST endpoint solely exists to avoid user confusion if they create a new account with
+ * {@code PUT /accounts/<account-identifier>} and then repeat the same request. Without this REST
+ * endpoint the second request would fail with {@code 404 Not Found}, which would be surprising to
+ * the user.
+ */
 @Singleton
 public class PutAccount implements RestModifyView<AccountResource, AccountInput> {
   @Override
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
index a6ffaa6..a80ab3f 100644
--- a/java/com/google/gerrit/server/restapi/account/PutActive.java
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -27,6 +27,15 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to mark an account as active.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/active} requests.
+ *
+ * <p>Only active accounts can login into Gerrit.
+ *
+ * <p>Marking an account as inactive is handled by {@link DeleteActive}.
+ */
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class PutActive implements RestModifyView<AccountResource, Input> {
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index b4b3314..42504a0 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -42,6 +42,11 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
+/**
+ * REST endpoint to sign a contributor agreement for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/agreements} requests.
+ */
 @Singleton
 public class PutAgreement implements RestModifyView<AccountResource, AgreementInput> {
   private final ProjectCache projectCache;
diff --git a/java/com/google/gerrit/server/restapi/account/PutEmail.java b/java/com/google/gerrit/server/restapi/account/PutEmail.java
index 6ee9003..747f848 100644
--- a/java/com/google/gerrit/server/restapi/account/PutEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/PutEmail.java
@@ -21,6 +21,22 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.inject.Singleton;
 
+/**
+ * REST endpoint for updating an existing email address of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT
+ * /accounts/<account-identifier>/emails/<email-identifier>} requests if the specified email address
+ * already exists for the account. If it doesn't exist yet, the request is handled by {@link
+ * CreateEmail}.
+ *
+ * <p>We do not support email address updates via this path, hence this REST endpoint always throws
+ * a {@link ResourceConflictException} which results in a {@code 409 Conflict} response.
+ *
+ * <p>This REST endpoint solely exists to avoid user confusion if they create a new email address
+ * with {@code PUT /accounts/<account-identifier>/emails/<email-identifier>} and then repeat the
+ * same request. Without this REST endpoint the second request would fail with {@code 404 Not
+ * Found}, which would be surprising to the user.
+ */
 @Singleton
 public class PutEmail implements RestModifyView<AccountResource.Email, EmailInput> {
   @Override
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index f84a9da..11bcf74 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.common.HttpPasswordInput;
@@ -44,9 +46,17 @@
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import java.util.Optional;
-import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set/delete the password for HTTP access of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/password.http} and {@code
+ * DELETE /accounts/<account-identifier>/password.http} requests.
+ *
+ * <p>Gerrit only stores the hash of the HTTP password, hence if an HTTP password was set it's not
+ * possible to get it back from Gerrit.
+ */
 @Singleton
 public class PutHttpPassword implements RestModifyView<AccountResource, HttpPasswordInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -144,7 +154,7 @@
     byte[] rand = new byte[LEN];
     rng.nextBytes(rand);
 
-    byte[] enc = Base64.encodeBase64(rand, false);
+    byte[] enc = BaseEncoding.base64().encode(rand).getBytes(UTF_8);
     StringBuilder r = new StringBuilder(enc.length);
     for (int i = 0; i < enc.length; i++) {
       if (enc[i] == '=') {
diff --git a/java/com/google/gerrit/server/restapi/account/PutName.java b/java/com/google/gerrit/server/restapi/account/PutName.java
index d5f6333c..c7496b9 100644
--- a/java/com/google/gerrit/server/restapi/account/PutName.java
+++ b/java/com/google/gerrit/server/restapi/account/PutName.java
@@ -38,6 +38,13 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set the full name of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/name} requests.
+ *
+ * <p>Whether a full name can be set depends on whether the used {@link Realm} supports this.
+ */
 @Singleton
 public class PutName implements RestModifyView<AccountResource, NameInput> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index 2ddea2f..32b5ff2 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -45,6 +45,15 @@
 import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set an email address as preferred email address for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT
+ * /accounts/<account-identifier>/emails/<email-identifier>/preferred} requests.
+ *
+ * <p>Users can only set an email address as preferred that is assigned to their account as external
+ * ID.
+ */
 @Singleton
 public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -145,6 +154,6 @@
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
-    return alreadyPreferred.get() ? Response.ok("") : Response.created("");
+    return alreadyPreferred.get() ? Response.ok() : Response.created();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/PutStatus.java b/java/com/google/gerrit/server/restapi/account/PutStatus.java
index 7e27489..106f39e 100644
--- a/java/com/google/gerrit/server/restapi/account/PutStatus.java
+++ b/java/com/google/gerrit/server/restapi/account/PutStatus.java
@@ -35,6 +35,14 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set the status of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/status} requests.
+ *
+ * <p>The account status is a free-form text that a user can set for the own account (e.g. the 'OOO'
+ * string is often used to signal that the user is out-of-office).
+ */
 @Singleton
 public class PutStatus implements RestModifyView<AccountResource, StatusInput> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index b9297c9..dc841b8 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -46,6 +46,17 @@
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set the username of an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/username} requests.
+ *
+ * <p>Whether a username can be set depends on whether the used {@link Realm} supports this.
+ *
+ * <p>Once set a username cannot be changed or deleted. Changing usernames is disallowed because
+ * they can be used in ref names that represent user-specific sandbox branches which can exist in
+ * any repository and we have no way to find and rename those refs.
+ */
 @Singleton
 public class PutUsername implements RestModifyView<AccountResource, UsernameInput> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index f9e753c..0d12fd4 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -53,6 +53,14 @@
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
 
+/**
+ * REST endpoint to query accounts.
+ *
+ * <p>This REST endpoint handles {@code GET /accounts/} requests.
+ *
+ * <p>The account queries are parsed by {@link AccountQueryBuilder} and executed by {@link
+ * AccountQueryProcessor}.
+ */
 public class QueryAccounts implements RestReadView<TopLevelResource> {
   private static final int MAX_SUGGEST_RESULTS = 100;
 
diff --git a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
index cf56965..3f25b3b 100644
--- a/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetDiffPreferences.java
@@ -37,6 +37,18 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
+/**
+ * REST endpoint to set diff preferences for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/preferences.diff}
+ * requests.
+ *
+ * <p>General preferences can be set by {@link SetPreferences} and edit preferences can be set by
+ * {@link SetEditPreferences}.
+ *
+ * <p>Default diff preferences that apply for all accounts can be set by {@link
+ * com.google.gerrit.server.restapi.config.SetDiffPreferences}.
+ */
 @Singleton
 public class SetDiffPreferences implements RestModifyView<AccountResource, DiffPreferencesInfo> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
index 085adaa..a15a751 100644
--- a/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetEditPreferences.java
@@ -37,6 +37,18 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
+/**
+ * REST endpoint to set edit preferences for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/preferences.edit}
+ * requests.
+ *
+ * <p>General preferences can be set by {@link SetPreferences} and diff preferences can be set by
+ * {@link SetDiffPreferences}.
+ *
+ * <p>Default edit preferences that apply for all accounts can be set by {@link
+ * com.google.gerrit.server.restapi.config.SetEditPreferences}.
+ */
 @Singleton
 public class SetEditPreferences implements RestModifyView<AccountResource, EditPreferencesInfo> {
 
diff --git a/java/com/google/gerrit/server/restapi/account/SetPreferences.java b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
index 3f2211e..a2b29ae 100644
--- a/java/com/google/gerrit/server/restapi/account/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/account/SetPreferences.java
@@ -41,6 +41,17 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to set general preferences for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/preferences} requests.
+ *
+ * <p>Diff preferences can be set by {@link SetDiffPreferences} and edit preferences can be set by
+ * {@link SetEditPreferences}.
+ *
+ * <p>Default general preferences that apply for all accounts can be set by {@link
+ * com.google.gerrit.server.restapi.config.SetPreferences}.
+ */
 @Singleton
 public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
   private final Provider<CurrentUser> self;
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index cdaa99d..c27bdd8 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -98,7 +98,8 @@
 
     @Override
     @SuppressWarnings("unchecked")
-    public Response<List<ChangeInfo>> apply(AccountResource rsrc) throws Exception {
+    public Response<List<ChangeInfo>> apply(AccountResource rsrc)
+        throws RestApiException, PermissionBackendException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to list stars of another account");
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index df3b58e..ae69ccd 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -34,8 +35,6 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -44,10 +43,11 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class Abandon extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
+public class Abandon
+    implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyResolver notifyResolver;
@@ -55,12 +55,12 @@
 
   @Inject
   Abandon(
+      BatchUpdate.Factory updateFactory,
       ChangeJson.Factory json,
-      RetryHelper retryHelper,
       AbandonOp.Factory abandonOpFactory,
       NotifyResolver notifyResolver,
       PatchSetUtil patchSetUtil) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyResolver = notifyResolver;
@@ -68,8 +68,7 @@
   }
 
   @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AbandonInput input)
+  public Response<ChangeInfo> apply(ChangeResource rsrc, AbandonInput input)
       throws RestApiException, UpdateException, PermissionBackendException, IOException,
           ConfigInvalidException {
     // Not allowed to abandon if the current patch set is locked.
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 0adb28e..cbc1b79 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -29,7 +30,6 @@
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -107,8 +107,9 @@
    * PUT request with a path was called but change edit wasn't created yet. Change edit is created
    * and PUT handler is called.
    */
+  @Singleton
   public static class Create
-      implements RestCollectionCreateView<ChangeResource, ChangeEditResource, Put.Input> {
+      implements RestCollectionCreateView<ChangeResource, ChangeEditResource, FileContentInput> {
     private final Put putEdit;
 
     @Inject
@@ -117,14 +118,15 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource resource, IdString id, Put.Input input)
-        throws AuthException, ResourceConflictException, BadRequestException, IOException,
+    public Response<?> apply(ChangeResource resource, IdString id, FileContentInput input)
+        throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      putEdit.apply(resource, id.get(), input.content);
+      putEdit.apply(resource, id.get(), input);
       return Response.none();
     }
   }
 
+  @Singleton
   public static class DeleteFile
       implements RestCollectionDeleteMissingView<ChangeResource, ChangeEditResource, Input> {
     private final DeleteContent deleteContent;
@@ -183,7 +185,8 @@
 
     @Override
     public Response<EditInfo> apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, PermissionBackendException {
+        throws AuthException, IOException, ResourceNotFoundException, ResourceConflictException,
+            PermissionBackendException {
       Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       if (!edit.isPresent()) {
         return Response.none();
@@ -268,11 +271,7 @@
 
   /** Put handler that is activated when PUT request is called on collection element. */
   @Singleton
-  public static class Put implements RestModifyView<ChangeEditResource, Put.Input> {
-    public static class Input {
-      @DefaultInput public RawInput content;
-    }
-
+  public static class Put implements RestModifyView<ChangeEditResource, FileContentInput> {
     private final ChangeEditModifier editModifier;
     private final GitRepositoryManager repositoryManager;
 
@@ -283,21 +282,25 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, BadRequestException, IOException,
+    public Response<?> apply(ChangeEditResource rsrc, FileContentInput input)
+        throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath(), input.content);
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), input);
     }
 
-    public Response<?> apply(ChangeResource rsrc, String path, RawInput newContent)
-        throws ResourceConflictException, AuthException, BadRequestException, IOException,
+    public Response<?> apply(ChangeResource rsrc, String path, FileContentInput input)
+        throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
 
+      if (input.content == null) {
+        throw new BadRequestException("new content required");
+      }
+
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
-        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
+        editModifier.modifyFile(repository, rsrc.getNotes(), path, input.content);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPick.java b/java/com/google/gerrit/server/restapi/change/CherryPick.java
index 1a89935..2902eca 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPick.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPick.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.RevisionResource;
@@ -38,9 +39,6 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.submit.IntegrationException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -49,8 +47,7 @@
 
 @Singleton
 public class CherryPick
-    extends RetryingRestModifyView<RevisionResource, CherryPickInput, CherryPickChangeInfo>
-    implements UiAction<RevisionResource> {
+    implements RestModifyView<RevisionResource, CherryPickInput>, UiAction<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
@@ -62,12 +59,10 @@
   @Inject
   CherryPick(
       PermissionBackend permissionBackend,
-      RetryHelper retryHelper,
       CherryPickChange cherryPickChange,
       ChangeJson.Factory json,
       ContributorAgreementsChecker contributorAgreements,
       ProjectCache projectCache) {
-    super(retryHelper);
     this.permissionBackend = permissionBackend;
     this.cherryPickChange = cherryPickChange;
     this.json = json;
@@ -76,8 +71,7 @@
   }
 
   @Override
-  public Response<CherryPickChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
+  public Response<CherryPickChangeInfo> apply(RevisionResource rsrc, CherryPickInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException {
     input.parent = input.parent == null ? 1 : input.parent;
@@ -98,7 +92,6 @@
     try {
       CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
-              updateFactory,
               rsrc.getChange(),
               rsrc.getPatchSet(),
               input,
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 8f6f0e6..ac81b45 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
@@ -41,6 +42,7 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -101,11 +103,13 @@
   private final Provider<IdentifiedUser> user;
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final SetCherryPickOp.Factory setCherryPickOfFactory;
   private final MergeUtil.Factory mergeUtilFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
+  private final BatchUpdate.Factory batchUpdateFactory;
 
   @Inject
   CherryPickChange(
@@ -116,11 +120,13 @@
       Provider<IdentifiedUser> user,
       ChangeInserter.Factory changeInserterFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
+      SetCherryPickOp.Factory setCherryPickOfFactory,
       MergeUtil.Factory mergeUtilFactory,
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
-      NotifyResolver notifyResolver) {
+      NotifyResolver notifyResolver,
+      BatchUpdate.Factory batchUpdateFactory) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -128,17 +134,18 @@
     this.user = user;
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
+    this.setCherryPickOfFactory = setCherryPickOfFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.projectCache = projectCache;
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
+    this.batchUpdateFactory = batchUpdateFactory;
   }
 
   /**
    * This function is used for cherry picking a change.
    *
-   * @param batchUpdateFactory Used for applying changes to the database.
    * @param change Change to cherry pick.
    * @param patch The patch of that change.
    * @param input Input object for different configurations of cherry pick.
@@ -153,21 +160,19 @@
    * @throws ConfigInvalidException Can't find account to notify.
    * @throws NoSuchProjectException Can't find project state.
    */
-  public Result cherryPick(
-      BatchUpdate.Factory batchUpdateFactory,
-      Change change,
-      PatchSet patch,
-      CherryPickInput input,
-      BranchNameKey dest)
+  public Result cherryPick(Change change, PatchSet patch, CherryPickInput input, BranchNameKey dest)
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        batchUpdateFactory,
         change,
         change.getProject(),
         patch.commitId(),
         input,
         dest,
+        false,
+        TimeUtil.nowTs(),
+        null,
+        null,
         null,
         null,
         null);
@@ -177,7 +182,6 @@
    * This function is called directly to cherry pick a commit. Also, it is used to cherry pick a
    * change as well as long as sourceChange is not null.
    *
-   * @param batchUpdateFactory Used for applying changes to the database.
    * @param sourceChange Change to cherry pick. Can be null, and then the function will only cherry
    *     pick a commit.
    * @param project Project name
@@ -195,7 +199,6 @@
    * @throws NoSuchProjectException Can't find project state.
    */
   public Result cherryPick(
-      BatchUpdate.Factory batchUpdateFactory,
       @Nullable Change sourceChange,
       Project.NameKey project,
       ObjectId sourceCommit,
@@ -204,7 +207,18 @@
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        batchUpdateFactory, sourceChange, project, sourceCommit, input, dest, null, null, null);
+        sourceChange,
+        project,
+        sourceCommit,
+        input,
+        dest,
+        false,
+        TimeUtil.nowTs(),
+        null,
+        null,
+        null,
+        null,
+        null);
   }
 
   /**
@@ -212,23 +226,30 @@
    * null) with a few other parameters that are especially useful for cherry-picking a commit that
    * is the revert-of another change.
    *
-   * @param batchUpdateFactory Used for applying changes to the database.
    * @param sourceChange Change to cherry pick. Can be null, and then the function will only cherry
    *     pick a commit.
    * @param project Project name
    * @param sourceCommit Id of the commit to be cherry picked.
    * @param input Input object for different configurations of cherry pick.
    * @param dest Destination branch for the cherry pick.
+   * @param ignoreIdenticalTree When false, we throw an error when trying to cherry-pick creates an
+   *     empty commit. When true, we allow creation of an empty commit.
+   * @param timestamp the current timestamp.
    * @param topic Topic name for the change created.
    * @param revertedChange The id of the change that is reverted. This is used for the "revertOf"
    *     field to mark the created cherry pick change as "revertOf" the original change that was
    *     reverted.
-   * @param changeIdForNewChange The Change-Id that the new change that of the cherry pick will
-   *     have.
+   * @param changeIdForNewChange The Change-Id that the new change of the cherry pick will have.
+   * @param idForNewChange The ID that the new change of the cherry pick will have. If provided and
+   *     the cherry-pick doesn't result in creating a new change, then
+   *     InvalidChangeOperationException is thrown.
+   * @param groupName The name of the group for grouping related changes (used by GetRelated
+   *     endpoint).
    * @return Result object that describes the cherry pick.
    * @throws IOException Unable to open repository or read from the database.
    * @throws InvalidChangeOperationException Parent or branch don't exist, or two changes with same
-   *     key exist in the branch.
+   *     key exist in the branch. Also thrown when idForNewChange is not null but cherry-pick only
+   *     creates a new patchset rather than a new change.
    * @throws IntegrationException Merge conflict or trees are identical after cherry pick.
    * @throws UpdateException Problem updating the database using batchUpdateFactory.
    * @throws RestApiException Error such as invalid SHA1
@@ -236,15 +257,18 @@
    * @throws NoSuchProjectException Can't find project state.
    */
   public Result cherryPick(
-      BatchUpdate.Factory batchUpdateFactory,
       @Nullable Change sourceChange,
       Project.NameKey project,
       ObjectId sourceCommit,
       CherryPickInput input,
       BranchNameKey dest,
+      boolean ignoreIdenticalTree,
+      Timestamp timestamp,
       @Nullable String topic,
       @Nullable Change.Id revertedChange,
-      @Nullable ObjectId changeIdForNewChange)
+      @Nullable ObjectId changeIdForNewChange,
+      @Nullable Change.Id idForNewChange,
+      @Nullable String groupName)
       throws IOException, InvalidChangeOperationException, IntegrationException, UpdateException,
           RestApiException, ConfigInvalidException, NoSuchProjectException {
 
@@ -277,8 +301,7 @@
       String message = Strings.nullToEmpty(input.message).trim();
       message = message.isEmpty() ? commitToCherryPick.getFullMessage() : message;
 
-      Timestamp now = TimeUtil.nowTs();
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
 
       final ObjectId generatedChangeId =
           changeIdForNewChange != null ? changeIdForNewChange : Change.generateChangeId();
@@ -308,7 +331,7 @@
                 commitMessage,
                 revWalk,
                 input.parent - 1,
-                false,
+                ignoreIdenticalTree,
                 input.allowConflicts);
 
         Change.Key changeKey;
@@ -330,14 +353,32 @@
                   + " reside on the same branch. "
                   + "Cannot create a new patch set.");
         }
-        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, now)) {
+        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
           bu.setRepository(git, revWalk, oi);
           bu.setNotify(resolveNotify(input));
           Change.Id changeId;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
-            // will be added as a new patch set.
-            changeId = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit);
+            // will be added as a new patch set. If "idForNewChange" is not null we must fail,
+            // since we are not expecting an already existing change.
+            if (idForNewChange != null) {
+              throw new InvalidChangeOperationException(
+                  String.format(
+                      "Expected that cherry-pick of commit %s with Change-Id %s to branch %s"
+                          + "in project %s creates a new change, but found existing change %d",
+                      sourceCommit.getName(),
+                      changeKey,
+                      dest.branch(),
+                      dest.project(),
+                      destChanges.get(0).getId().get()));
+            }
+            changeId =
+                insertPatchSet(
+                    bu,
+                    git,
+                    destChanges.get(0).notes(),
+                    cherryPickCommit,
+                    sourceChange.currentPatchSetId());
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -356,7 +397,9 @@
                     sourceChange,
                     sourceCommit,
                     input,
-                    revertedChange);
+                    revertedChange,
+                    idForNewChange,
+                    groupName);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -419,13 +462,22 @@
   }
 
   private Change.Id insertPatchSet(
-      BatchUpdate bu, Repository git, ChangeNotes destNotes, CodeReviewCommit cherryPickCommit)
+      BatchUpdate bu,
+      Repository git,
+      ChangeNotes destNotes,
+      CodeReviewCommit cherryPickCommit,
+      PatchSet.Id sourcePatchSetId)
       throws IOException {
     Change destChange = destNotes.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
     inserter.setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".");
     bu.addOp(destChange.getId(), inserter);
+    if (destChange.getCherryPickOf() == null
+        || !destChange.getCherryPickOf().equals(sourcePatchSetId)) {
+      SetCherryPickOp cherryPickOfUpdater = setCherryPickOfFactory.create(sourcePatchSetId);
+      bu.addOp(destChange.getId(), cherryPickOfUpdater);
+    }
     return destChange.getId();
   }
 
@@ -437,12 +489,15 @@
       @Nullable Change sourceChange,
       @Nullable ObjectId sourceCommit,
       CherryPickInput input,
-      @Nullable Change.Id revertOf)
+      @Nullable Change.Id revertOf,
+      @Nullable Change.Id idForNewChange,
+      @Nullable String groupName)
       throws IOException {
-    Change.Id changeId = Change.id(seq.nextChangeId());
+    Change.Id changeId = idForNewChange != null ? idForNewChange : Change.id(seq.nextChangeId());
     ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
     ins.setRevertOf(revertOf);
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
             revertOf == null
                 ? messageForDestinationChange(
@@ -450,6 +505,7 @@
                 : "Uploaded patch set 1.") // For revert commits, the message should not include
         // cherry-pick information.
         .setTopic(topic)
+        .setCherryPickOf(sourcePatchSetId)
         .setWorkInProgress(
             (sourceChange != null && sourceChange.isWorkInProgress())
                 || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
@@ -463,6 +519,9 @@
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.get().getAccountId());
       ins.setReviewersAndCcs(reviewers, ccs);
+      if (groupName != null) {
+        ins.setGroups(ImmutableList.of(groupName));
+      }
     }
     bu.insertChange(ins);
     return changeId;
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
index a3c8a97..8b5b07c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickCommit.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -34,9 +35,6 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.IntegrationException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -45,8 +43,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class CherryPickCommit
-    extends RetryingRestModifyView<CommitResource, CherryPickInput, CherryPickChangeInfo> {
+public class CherryPickCommit implements RestModifyView<CommitResource, CherryPickInput> {
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CherryPickChange cherryPickChange;
@@ -55,13 +52,11 @@
 
   @Inject
   CherryPickCommit(
-      RetryHelper retryHelper,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       CherryPickChange cherryPickChange,
       ChangeJson.Factory json,
-      PermissionBackend permissionBackend,
       ContributorAgreementsChecker contributorAgreements) {
-    super(retryHelper);
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.cherryPickChange = cherryPickChange;
@@ -70,8 +65,7 @@
   }
 
   @Override
-  public Response<CherryPickChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
+  public Response<CherryPickChangeInfo> apply(CommitResource rsrc, CherryPickInput input)
       throws IOException, UpdateException, RestApiException, PermissionBackendException,
           ConfigInvalidException, NoSuchProjectException {
     String destination = Strings.nullToEmpty(input.destination).trim();
@@ -94,7 +88,6 @@
     try {
       CherryPickChange.Result cherryPickResult =
           cherryPickChange.cherryPick(
-              updateFactory,
               null,
               projectName,
               rsrc.getCommit(),
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index acc6465..537993a 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
@@ -27,6 +28,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -40,6 +42,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.CurrentUser;
@@ -69,8 +72,6 @@
 import com.google.gerrit.server.restapi.project.CommitsCollection;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -86,7 +87,6 @@
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.NoMergeBaseException;
-import org.eclipse.jgit.errors.NoMergeBaseException.MergeBaseFailureReason;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -103,8 +103,10 @@
 
 @Singleton
 public class CreateChange
-    extends RetryingRestCollectionModifyView<
-        TopLevelResource, ChangeResource, ChangeInput, ChangeInfo> {
+    implements RestCollectionModifyView<TopLevelResource, ChangeResource, ChangeInput> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final BatchUpdate.Factory updateFactory;
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
@@ -126,6 +128,7 @@
 
   @Inject
   CreateChange(
+      BatchUpdate.Factory updateFactory,
       @AnonymousCowardName String anonymousCowardName,
       GitRepositoryManager gitManager,
       Sequences seq,
@@ -138,13 +141,12 @@
       ChangeJson.Factory json,
       ChangeFinder changeFinder,
       Provider<InternalChangeQuery> queryProvider,
-      RetryHelper retryHelper,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
       MergeUtil.Factory mergeUtilFactory,
       NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreements) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.anonymousCowardName = anonymousCowardName;
     this.gitManager = gitManager;
     this.seq = seq;
@@ -166,8 +168,7 @@
   }
 
   @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
+  public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
       throws IOException, InvalidChangeOperationException, RestApiException, UpdateException,
           PermissionBackendException, ConfigInvalidException {
     if (!user.get().isIdentifiedUser()) {
@@ -295,20 +296,30 @@
       BatchUpdate.Factory updateFactory)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
+    logger.atFine().log(
+        "Creating new change for target branch %s in project %s"
+            + " (new branch = %s, base change = %s, base commit = %s)",
+        input.branch, projectState.getName(), input.newBranch, input.baseChange, input.baseCommit);
+
     try (Repository git = gitManager.openRepository(projectState.getNameKey());
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = null;
       List<String> groups = Collections.emptyList();
+
       if (input.baseChange != null) {
         ChangeNotes baseChange = getBaseChange(input.baseChange);
         basePatchSet = psUtil.current(baseChange);
         groups = basePatchSet.groups();
+        logger.atFine().log("base patch set = %s (groups = %s)", basePatchSet.id(), groups);
       }
+
       ObjectId parentCommit =
           getParentCommit(
               git, rw, input.branch, input.newBranch, basePatchSet, input.baseCommit, input.merge);
+      logger.atFine().log(
+          "parent commit = %s", parentCommit != null ? parentCommit.name() : "NULL");
 
       RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
@@ -341,8 +352,8 @@
         bu.execute();
       }
       return ins.getChange();
-    } catch (IllegalArgumentException e) {
-      throw new BadRequestException(e.getMessage(), e);
+    } catch (InvalidMergeStrategyException e) {
+      throw new BadRequestException(e.getMessage());
     }
   }
 
@@ -465,6 +476,7 @@
       RevCommit mergeTip,
       String commitMessage)
       throws IOException {
+    logger.atFine().log("Creating empty commit");
     CommitBuilder commit = new CommitBuilder();
     if (mergeTip == null) {
       commit.setTreeId(emptyTreeId(oi));
@@ -488,12 +500,21 @@
       PersonIdent authorIdent,
       String commitMessage)
       throws RestApiException, IOException {
+    logger.atFine().log(
+        "Creating merge commit: source = %s, strategy = %s", merge.source, merge.strategy);
+
     if (Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
 
     RevCommit sourceCommit = MergeUtil.resolveCommit(repo, rw, merge.source);
-    if (!commits.canRead(projectState, repo, sourceCommit)) {
+    if (merge.sourceBranch != null) {
+      Ref ref = repo.findRef(merge.sourceBranch);
+      logger.atFine().log("checking visibility for branch %s", merge.sourceBranch);
+      if (ref == null || !commits.canRead(projectState, repo, sourceCommit, ref)) {
+        throw new BadRequestException("do not have read permission for: " + merge.source);
+      }
+    } else if (!commits.canRead(projectState, repo, sourceCommit)) {
       throw new BadRequestException("do not have read permission for: " + merge.source);
     }
 
@@ -501,6 +522,7 @@
     // default merge strategy from project settings
     String mergeStrategy =
         firstNonNull(Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
+    logger.atFine().log("merge strategy = %s", mergeStrategy);
 
     try {
       return MergeUtil.createMergeCommit(
@@ -513,12 +535,8 @@
           commitMessage,
           rw);
     } catch (NoMergeBaseException e) {
-      if (MergeBaseFailureReason.TOO_MANY_MERGE_BASES == e.getReason()
-          || MergeBaseFailureReason.CONFLICTS_DURING_MERGE_BASE_CALCULATION == e.getReason()) {
-        throw new ResourceConflictException(
-            String.format("Cannot create merge commit: %s", e.getMessage()), e);
-      }
-      throw e;
+      throw new ResourceConflictException(
+          String.format("Cannot create merge commit: %s", e.getMessage()), e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index f434e31..5b7245d 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.CommentsUtil;
@@ -36,8 +37,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -46,8 +45,8 @@
 import java.util.Collections;
 
 @Singleton
-public class CreateDraftComment
-    extends RetryingRestModifyView<RevisionResource, DraftInput, CommentInfo> {
+public class CreateDraftComment implements RestModifyView<RevisionResource, DraftInput> {
+  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -55,12 +54,12 @@
 
   @Inject
   CreateDraftComment(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -68,8 +67,7 @@
   }
 
   @Override
-  protected Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
+  public Response<CommentInfo> apply(RevisionResource rsrc, DraftInput in)
       throws RestApiException, UpdateException, PermissionBackendException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 4c6a728..06478ac 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -53,8 +54,6 @@
 import com.google.gerrit.server.restapi.project.CommitsCollection;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -75,8 +74,8 @@
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
-public class CreateMergePatchSet
-    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, ChangeInfo> {
+public class CreateMergePatchSet implements RestModifyView<ChangeResource, MergePatchSetInput> {
+  private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
   private final TimeZone serverTimeZone;
@@ -91,6 +90,7 @@
 
   @Inject
   CreateMergePatchSet(
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager gitManager,
       CommitsCollection commits,
       @GerritPersonIdent PersonIdent myIdent,
@@ -98,12 +98,11 @@
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
       MergeUtil.Factory mergeUtilFactory,
-      RetryHelper retryHelper,
       PatchSetInserter.Factory patchSetInserterFactory,
       ProjectCache projectCache,
       ChangeFinder changeFinder,
       PermissionBackend permissionBackend) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.gitManager = gitManager;
     this.commits = commits;
     this.serverTimeZone = myIdent.getTimeZone();
@@ -118,8 +117,7 @@
   }
 
   @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
+  public Response<ChangeInfo> apply(ChangeResource rsrc, MergePatchSetInput in)
       throws IOException, RestApiException, UpdateException, PermissionBackendException {
     // Not allowed to create a new patch set if the current patch set is locked.
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 834782f..20fd675 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
@@ -34,16 +35,14 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteAssignee extends RetryingRestModifyView<ChangeResource, Input, AccountInfo> {
-
+public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeMessagesUtil cmUtil;
   private final AssigneeChanged assigneeChanged;
   private final IdentifiedUser.GenericFactory userFactory;
@@ -51,12 +50,12 @@
 
   @Inject
   DeleteAssignee(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       ChangeMessagesUtil cmUtil,
       AssigneeChanged assigneeChanged,
       IdentifiedUser.GenericFactory userFactory,
       AccountLoader.Factory accountLoaderFactory) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.cmUtil = cmUtil;
     this.assigneeChanged = assigneeChanged;
     this.userFactory = userFactory;
@@ -64,8 +63,7 @@
   }
 
   @Override
-  protected Response<AccountInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+  public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index aa4dcf0..3ca5463 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.DeleteChangeOp;
@@ -28,28 +29,25 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Object>
-    implements UiAction<ChangeResource> {
-
+public class DeleteChange
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
   private final DeleteChangeOp.Factory opFactory;
 
   @Inject
-  public DeleteChange(RetryHelper retryHelper, DeleteChangeOp.Factory opFactory) {
-    super(retryHelper);
+  public DeleteChange(BatchUpdate.Factory updateFactory, DeleteChangeOp.Factory opFactory) {
+    this.updateFactory = updateFactory;
     this.opFactory = opFactory;
   }
 
   @Override
-  protected Response<Object> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+  public Response<Object> apply(ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     if (!isChangeDeletable(rsrc)) {
       throw new MethodNotAllowedException("delete not permitted");
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 30cfad6..f79209d 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
@@ -39,8 +40,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -52,11 +51,11 @@
 /** Deletes a change message by rewriting history. */
 @Singleton
 public class DeleteChangeMessage
-    extends RetryingRestModifyView<
-        ChangeMessageResource, DeleteChangeMessageInput, ChangeMessageInfo> {
+    implements RestModifyView<ChangeMessageResource, DeleteChangeMessageInput> {
 
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final AccountLoader.Factory accountLoaderFactory;
   private final ChangeNotes.Factory notesFactory;
@@ -65,23 +64,21 @@
   public DeleteChangeMessage(
       Provider<CurrentUser> userProvider,
       PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
       ChangeMessagesUtil changeMessagesUtil,
       AccountLoader.Factory accountLoaderFactory,
-      ChangeNotes.Factory notesFactory,
-      RetryHelper retryHelper) {
-    super(retryHelper);
+      ChangeNotes.Factory notesFactory) {
     this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
     this.changeMessagesUtil = changeMessagesUtil;
     this.accountLoaderFactory = accountLoaderFactory;
     this.notesFactory = notesFactory;
   }
 
   @Override
-  public Response<ChangeMessageInfo> applyImpl(
-      BatchUpdate.Factory updateFactory,
-      ChangeMessageResource resource,
-      DeleteChangeMessageInput input)
+  public Response<ChangeMessageInfo> apply(
+      ChangeMessageResource resource, DeleteChangeMessageInput input)
       throws RestApiException, PermissionBackendException, UpdateException, IOException {
     CurrentUser user = userProvider.get();
     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
@@ -146,21 +143,18 @@
 
   @Singleton
   public static class DefaultDeleteChangeMessage
-      extends RetryingRestModifyView<ChangeMessageResource, Input, ChangeMessageInfo> {
+      implements RestModifyView<ChangeMessageResource, Input> {
     private final DeleteChangeMessage deleteChangeMessage;
 
     @Inject
-    public DefaultDeleteChangeMessage(
-        DeleteChangeMessage deleteChangeMessage, RetryHelper retryHelper) {
-      super(retryHelper);
+    public DefaultDeleteChangeMessage(DeleteChangeMessage deleteChangeMessage) {
       this.deleteChangeMessage = deleteChangeMessage;
     }
 
     @Override
-    protected Response<ChangeMessageInfo> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeMessageResource resource, Input input)
-        throws Exception {
-      return deleteChangeMessage.applyImpl(updateFactory, resource, new DeleteChangeMessageInput());
+    public Response<ChangeMessageInfo> apply(ChangeMessageResource resource, Input input)
+        throws RestApiException, PermissionBackendException, UpdateException, IOException {
+      return deleteChangeMessage.apply(resource, new DeleteChangeMessageInput());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 95479a6..f915728 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.CommentResource;
@@ -33,8 +34,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -46,11 +45,11 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteComment
-    extends RetryingRestModifyView<CommentResource, DeleteCommentInput, CommentInfo> {
+public class DeleteComment implements RestModifyView<CommentResource, DeleteCommentInput> {
 
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
   private final CommentsUtil commentsUtil;
   private final Provider<CommentJson> commentJson;
   private final ChangeNotes.Factory notesFactory;
@@ -59,21 +58,20 @@
   public DeleteComment(
       Provider<CurrentUser> userProvider,
       PermissionBackend permissionBackend,
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       CommentsUtil commentsUtil,
       Provider<CommentJson> commentJson,
       ChangeNotes.Factory notesFactory) {
-    super(retryHelper);
     this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
     this.commentsUtil = commentsUtil;
     this.commentJson = commentJson;
     this.notesFactory = notesFactory;
   }
 
   @Override
-  public Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
+  public Response<CommentInfo> apply(CommentResource rsrc, DeleteCommentInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
           UpdateException {
     CurrentUser user = userProvider.get();
@@ -86,8 +84,7 @@
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
     try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(
-            rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
       batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 9296988..89fc3b7 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
@@ -31,8 +32,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -41,28 +40,26 @@
 import java.util.Optional;
 
 @Singleton
-public class DeleteDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, Input, CommentInfo> {
-
+public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
+  private final BatchUpdate.Factory updateFactory;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(
+      BatchUpdate.Factory updateFactory,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      RetryHelper retryHelper,
       PatchListCache patchListCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
   }
 
   @Override
-  protected Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, Input input)
+  public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index de7a683..16b7136 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -17,42 +17,41 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeletePrivate
-    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String> {
+public class DeletePrivate implements RestModifyView<ChangeResource, InputWithMessage> {
   private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
 
   @Inject
   DeletePrivate(
-      RetryHelper retryHelper,
       PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
       SetPrivateOp.Factory setPrivateOpFactory) {
-    super(retryHelper);
     this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
   }
 
   @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, @Nullable SetPrivateOp.Input input)
+  public Response<String> apply(ChangeResource rsrc, @Nullable InputWithMessage input)
       throws RestApiException, UpdateException {
     if (!canDeletePrivate(rsrc).value()) {
       throw new AuthException("not allowed to unmark private");
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java b/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
index c86d0ca..10feb63 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivateByPost.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -28,10 +28,10 @@
 public class DeletePrivateByPost extends DeletePrivate implements UiAction<ChangeResource> {
   @Inject
   DeletePrivateByPost(
-      RetryHelper retryHelper,
       PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
       SetPrivateOp.Factory setPrivateOpFactory) {
-    super(retryHelper, permissionBackend, setPrivateOpFactory);
+    super(permissionBackend, updateFactory, setPrivateOpFactory);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index b98bb3b..3e4a483 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -19,39 +19,36 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
 import com.google.gerrit.server.change.DeleteReviewerOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteReviewer
-    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Object> {
-
+public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
+  private final BatchUpdate.Factory updateFactory;
   private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
   private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
   DeleteReviewer(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       DeleteReviewerOp.Factory deleteReviewerOpFactory,
       DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.deleteReviewerOpFactory = deleteReviewerOpFactory;
     this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   @Override
-  protected Response<Object> applyImpl(
-      BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
+  public Response<Object> apply(ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
     if (input == null) {
       input = new DeleteReviewerInput();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 1193ad6..bdbf3f7 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -51,8 +52,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -64,9 +63,10 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Object> {
+public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final BatchUpdate.Factory updateFactory;
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
@@ -79,7 +79,7 @@
 
   @Inject
   DeleteVote(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
@@ -89,7 +89,7 @@
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
@@ -102,8 +102,7 @@
   }
 
   @Override
-  protected Response<Object> applyImpl(
-      BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
+  public Response<Object> apply(VoteResource rsrc, DeleteVoteInput input)
       throws RestApiException, UpdateException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new DeleteVoteInput();
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index a9b3aa0..c3deb79 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -14,42 +14,34 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.common.data.PatchScript.DisplayMethod;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
-import com.google.gerrit.extensions.common.DiffInfo.FileMeta;
-import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.jgit.diff.ReplaceEdit;
-import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.diff.DiffInfoCreator;
+import com.google.gerrit.server.diff.DiffSide;
+import com.google.gerrit.server.diff.DiffWebLinksProvider;
 import com.google.gerrit.server.git.LargeObjectException;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchScriptFactory;
@@ -60,10 +52,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.List;
-import java.util.Set;
 import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.diff.Edit;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.NamedOptionDef;
@@ -76,17 +65,6 @@
 public class GetDiff implements RestReadView<FileResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final ImmutableMap<Patch.ChangeType, ChangeType> CHANGE_TYPE =
-      Maps.immutableEnumMap(
-          new ImmutableMap.Builder<Patch.ChangeType, ChangeType>()
-              .put(Patch.ChangeType.ADDED, ChangeType.ADDED)
-              .put(Patch.ChangeType.MODIFIED, ChangeType.MODIFIED)
-              .put(Patch.ChangeType.DELETED, ChangeType.DELETED)
-              .put(Patch.ChangeType.RENAMED, ChangeType.RENAMED)
-              .put(Patch.ChangeType.COPIED, ChangeType.COPIED)
-              .put(Patch.ChangeType.REWRITE, ChangeType.REWRITE)
-              .build());
-
   private final ProjectCache projectCache;
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
   private final Revisions revisions;
@@ -125,8 +103,8 @@
 
   @Override
   public Response<DiffInfo> apply(FileResource resource)
-      throws ResourceConflictException, ResourceNotFoundException, AuthException,
-          InvalidChangeOperationException, IOException, PermissionBackendException {
+      throws BadRequestException, ResourceConflictException, ResourceNotFoundException,
+          AuthException, InvalidChangeOperationException, IOException, PermissionBackendException {
     DiffPreferencesInfo prefs = new DiffPreferencesInfo();
     if (whitespace != null) {
       prefs.ignoreWhitespace = whitespace;
@@ -137,16 +115,25 @@
     }
     prefs.context = context;
     prefs.intralineDifference = intraline;
+    logger.atFine().log(
+        "diff preferences: ignoreWhitespace = %s, context = %s, intralineDifference = %s",
+        prefs.ignoreWhitespace, prefs.context, prefs.intralineDifference);
 
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
     PatchSet.Id pId = resource.getPatchKey().patchSetId();
     String fileName = resource.getPatchKey().fileName();
+    logger.atFine().log(
+        "patchSetId = %d, fileName = %s, base = %s, parentNum = %d",
+        pId.get(), fileName, base, parentNum);
     ChangeNotes notes = resource.getRevision().getNotes();
     if (base != null) {
       RevisionResource baseResource =
           revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
+      if (basePatchSet.id().get() == 0) {
+        throw new BadRequestException("edit not allowed as base");
+      }
       psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
     } else if (parentNum > 0) {
       psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
@@ -158,105 +145,18 @@
       psf.setLoadHistory(false);
       psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
       PatchScript ps = psf.call();
-      Content content = new Content(ps);
-      Set<Edit> editsDueToRebase = ps.getEditsDueToRebase();
-      for (Edit edit : ps.getEdits()) {
-        if (edit.getType() == Edit.Type.EMPTY) {
-          continue;
-        }
-        content.addCommon(edit.getBeginA());
-
-        checkState(
-            content.nextA == edit.getBeginA(),
-            "nextA = %s; want %s",
-            content.nextA,
-            edit.getBeginA());
-        checkState(
-            content.nextB == edit.getBeginB(),
-            "nextB = %s; want %s",
-            content.nextB,
-            edit.getBeginB());
-        switch (edit.getType()) {
-          case DELETE:
-          case INSERT:
-          case REPLACE:
-            List<Edit> internalEdit =
-                edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
-            boolean dueToRebase = editsDueToRebase.contains(edit);
-            content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
-            break;
-          case EMPTY:
-          default:
-            throw new IllegalStateException();
-        }
-      }
-      content.addCommon(ps.getA().size());
-
-      ProjectState state = projectCache.get(resource.getRevision().getChange().getProject());
-
-      DiffInfo result = new DiffInfo();
-      String revA = basePatchSet != null ? basePatchSet.refName() : content.commitIdA;
-      String revB =
-          resource.getRevision().getEdit().isPresent()
-              ? resource.getRevision().getEdit().get().getRefName()
-              : resource.getRevision().getPatchSet().refName();
-
-      ImmutableList<DiffWebLinkInfo> links =
-          webLinks.getDiffLinks(
-              state.getName(),
-              resource.getPatchKey().patchSetId().changeId().get(),
-              basePatchSet != null ? basePatchSet.id().get() : null,
-              revA,
+      Project.NameKey projectName = resource.getRevision().getChange().getProject();
+      ProjectState state = projectCache.get(projectName);
+      DiffSide sideA =
+          DiffSide.create(
+              ps.getFileInfoA(),
               MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-              resource.getPatchKey().patchSetId().get(),
-              revB,
-              ps.getNewName());
-      result.webLinks = links.isEmpty() ? null : links;
-
-      if (ps.isBinary()) {
-        result.binary = true;
-      }
-      if (ps.getDisplayMethodA() != DisplayMethod.NONE) {
-        result.metaA = new FileMeta();
-        result.metaA.name = MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName());
-        result.metaA.contentType =
-            FileContentUtil.resolveContentType(
-                state, result.metaA.name, ps.getFileModeA(), ps.getMimeTypeA());
-        result.metaA.lines = ps.getA().size();
-        result.metaA.webLinks = getFileWebLinks(state.getProject(), revA, result.metaA.name);
-        result.metaA.commitId = content.commitIdA;
-      }
-
-      if (ps.getDisplayMethodB() != DisplayMethod.NONE) {
-        result.metaB = new FileMeta();
-        result.metaB.name = ps.getNewName();
-        result.metaB.contentType =
-            FileContentUtil.resolveContentType(
-                state, result.metaB.name, ps.getFileModeB(), ps.getMimeTypeB());
-        result.metaB.lines = ps.getB().size();
-        result.metaB.webLinks = getFileWebLinks(state.getProject(), revB, result.metaB.name);
-        result.metaB.commitId = content.commitIdB;
-      }
-
-      if (intraline) {
-        if (ps.hasIntralineTimeout()) {
-          result.intralineStatus = IntraLineStatus.TIMEOUT;
-        } else if (ps.hasIntralineFailure()) {
-          result.intralineStatus = IntraLineStatus.FAILURE;
-        } else {
-          result.intralineStatus = IntraLineStatus.OK;
-        }
-      }
-
-      result.changeType = CHANGE_TYPE.get(ps.getChangeType());
-      if (result.changeType == null) {
-        throw new IllegalStateException("unknown change type: " + ps.getChangeType());
-      }
-
-      if (ps.getPatchHeader().size() > 0) {
-        result.diffHeader = ps.getPatchHeader();
-      }
-      result.content = content.lines;
+              DiffSide.Type.SIDE_A);
+      DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
+      DiffWebLinksProvider webLinksProvider =
+          new DiffWebLinksProviderImpl(sideA, sideB, projectName, basePatchSet, webLinks, resource);
+      DiffInfoCreator diffInfoCreator = new DiffInfoCreator(state, webLinksProvider, intraline);
+      DiffInfo result = diffInfoCreator.create(ps, sideA, sideB);
 
       Response<DiffInfo> r = Response.ok(result);
       if (resource.isCacheable()) {
@@ -270,9 +170,69 @@
     }
   }
 
-  private List<WebLinkInfo> getFileWebLinks(Project project, String rev, String file) {
-    ImmutableList<WebLinkInfo> links = webLinks.getFileLinks(project.getName(), rev, file);
-    return links.isEmpty() ? null : links;
+  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
+
+    private final WebLinks webLinks;
+    private final Project.NameKey projectName;
+    private final DiffSide sideA;
+    private final DiffSide sideB;
+    private final String revA;
+    private final String revB;
+    private final FileResource resource;
+    @Nullable private final PatchSet basePatchSet;
+
+    DiffWebLinksProviderImpl(
+        DiffSide sideA,
+        DiffSide sideB,
+        Project.NameKey projectName,
+        @Nullable PatchSet basePatchSet,
+        WebLinks webLinks,
+        FileResource resource) {
+      this.projectName = projectName;
+      this.webLinks = webLinks;
+      this.basePatchSet = basePatchSet;
+      this.resource = resource;
+      this.sideA = sideA;
+      this.sideB = sideB;
+
+      revA = basePatchSet != null ? basePatchSet.refName() : sideA.fileInfo().commitId;
+
+      RevisionResource revision = resource.getRevision();
+      revB =
+          revision
+              .getEdit()
+              .map(edit -> edit.getRefName())
+              .orElseGet(() -> revision.getPatchSet().refName());
+
+      logger.atFine().log("revA = %s, revB = %s", revA, revB);
+    }
+
+    @Override
+    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
+      return webLinks.getDiffLinks(
+          projectName.get(),
+          resource.getPatchKey().patchSetId().changeId().get(),
+          basePatchSet != null ? basePatchSet.id().get() : null,
+          revA,
+          sideA.fileName(),
+          resource.getPatchKey().patchSetId().get(),
+          revB,
+          sideB.fileName());
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
+      String rev;
+      DiffSide side;
+      if (type == DiffSide.Type.SIDE_A) {
+        rev = revA;
+        side = sideA;
+      } else {
+        rev = revB;
+        side = sideB;
+      }
+      return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
+    }
   }
 
   public GetDiff setBase(String base) {
@@ -300,110 +260,6 @@
     return this;
   }
 
-  private static class Content {
-    final List<ContentEntry> lines;
-    final SparseFileContent fileA;
-    final SparseFileContent fileB;
-    final boolean ignoreWS;
-    final String commitIdA;
-    final String commitIdB;
-
-    int nextA;
-    int nextB;
-
-    Content(PatchScript ps) {
-      lines = Lists.newArrayListWithExpectedSize(ps.getEdits().size() + 2);
-      fileA = ps.getA();
-      fileB = ps.getB();
-      ignoreWS = ps.isIgnoreWhitespace();
-      commitIdA = ps.getCommitIdA();
-      commitIdB = ps.getCommitIdB();
-    }
-
-    void addCommon(int end) {
-      end = Math.min(end, fileA.size());
-      if (nextA >= end) {
-        return;
-      }
-
-      while (nextA < end) {
-        if (!fileA.contains(nextA)) {
-          int endRegion = Math.min(end, nextA == 0 ? fileA.first() : fileA.next(nextA - 1));
-          int len = endRegion - nextA;
-          entry().skip = len;
-          nextA = endRegion;
-          nextB += len;
-          continue;
-        }
-
-        ContentEntry e = null;
-        for (int i = nextA; i == nextA && i < end; i = fileA.next(i), nextA++, nextB++) {
-          if (ignoreWS && fileB.contains(nextB)) {
-            if (e == null || e.common == null) {
-              e = entry();
-              e.a = Lists.newArrayListWithCapacity(end - nextA);
-              e.b = Lists.newArrayListWithCapacity(end - nextA);
-              e.common = true;
-            }
-            e.a.add(fileA.get(nextA));
-            e.b.add(fileB.get(nextB));
-          } else {
-            if (e == null || e.common != null) {
-              e = entry();
-              e.ab = Lists.newArrayListWithCapacity(end - nextA);
-            }
-            e.ab.add(fileA.get(nextA));
-          }
-        }
-      }
-    }
-
-    void addDiff(int endA, int endB, List<Edit> internalEdit, boolean dueToRebase) {
-      int lenA = endA - nextA;
-      int lenB = endB - nextB;
-      checkState(lenA > 0 || lenB > 0);
-
-      ContentEntry e = entry();
-      if (lenA > 0) {
-        e.a = Lists.newArrayListWithCapacity(lenA);
-        for (; nextA < endA; nextA++) {
-          e.a.add(fileA.get(nextA));
-        }
-      }
-      if (lenB > 0) {
-        e.b = Lists.newArrayListWithCapacity(lenB);
-        for (; nextB < endB; nextB++) {
-          e.b.add(fileB.get(nextB));
-        }
-      }
-      if (internalEdit != null && !internalEdit.isEmpty()) {
-        e.editA = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
-        e.editB = Lists.newArrayListWithCapacity(internalEdit.size() * 2);
-        int lastA = 0;
-        int lastB = 0;
-        for (Edit edit : internalEdit) {
-          if (edit.getBeginA() != edit.getEndA()) {
-            e.editA.add(
-                ImmutableList.of(edit.getBeginA() - lastA, edit.getEndA() - edit.getBeginA()));
-            lastA = edit.getEndA();
-          }
-          if (edit.getBeginB() != edit.getEndB()) {
-            e.editB.add(
-                ImmutableList.of(edit.getBeginB() - lastB, edit.getEndB() - edit.getBeginB()));
-            lastB = edit.getEndB();
-          }
-        }
-      }
-      e.dueToRebase = dueToRebase ? true : null;
-    }
-
-    private ContentEntry entry() {
-      ContentEntry e = new ContentEntry();
-      lines.add(e);
-      return e;
-    }
-  }
-
   @Deprecated
   enum IgnoreWhitespace {
     NONE(DiffPreferencesInfo.Whitespace.IGNORE_NONE),
@@ -419,6 +275,7 @@
   }
 
   public static class ContextOptionHandler extends OptionHandler<Short> {
+
     public ContextOptionHandler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
       super(parser, option, setter);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
new file mode 100644
index 0000000..0666756
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.diff.DiffInfoCreator;
+import com.google.gerrit.server.diff.DiffSide;
+import com.google.gerrit.server.diff.DiffSide.Type;
+import com.google.gerrit.server.diff.DiffWebLinksProvider;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class GetFixPreview implements RestReadView<FixResource> {
+
+  private final ProjectCache projectCache;
+  private final GitRepositoryManager repoManager;
+  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
+
+  @Inject
+  GetFixPreview(
+      ProjectCache projectCache,
+      GitRepositoryManager repoManager,
+      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory) {
+    this.projectCache = projectCache;
+    this.repoManager = repoManager;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+  }
+
+  @Override
+  public Response<Map<String, DiffInfo>> apply(FixResource resource)
+      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+          AuthException, IOException, InvalidChangeOperationException {
+    Map<String, DiffInfo> result = new HashMap<>();
+    PatchSet patchSet = resource.getRevisionResource().getPatchSet();
+    ChangeNotes notes = resource.getRevisionResource().getNotes();
+    Change change = notes.getChange();
+    ProjectState state = projectCache.get(change.getProject());
+    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+        resource.getFixReplacements().stream()
+            .collect(groupingBy(fixReplacement -> fixReplacement.path));
+    try {
+      try (Repository git = repoManager.openRepository(notes.getProjectName())) {
+        for (Map.Entry<String, List<FixReplacement>> entry :
+            fixReplacementsPerFilePath.entrySet()) {
+          String fileName = entry.getKey();
+          DiffInfo diffInfo =
+              getFixPreviewForSingleFile(
+                  git, patchSet, state, notes, fileName, ImmutableList.copyOf(entry.getValue()));
+          result.put(fileName, diffInfo);
+        }
+      }
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } catch (LargeObjectException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+    return Response.ok(result);
+  }
+
+  private DiffInfo getFixPreviewForSingleFile(
+      Repository git,
+      PatchSet patchSet,
+      ProjectState state,
+      ChangeNotes notes,
+      String fileName,
+      ImmutableList<FixReplacement> fixReplacements)
+      throws PermissionBackendException, AuthException, LargeObjectException,
+          InvalidChangeOperationException, IOException {
+    PatchScriptFactoryForAutoFix psf =
+        patchScriptFactoryFactory.create(
+            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
+    PatchScript ps = psf.call();
+
+    DiffSide sideA =
+        DiffSide.create(
+            ps.getFileInfoA(),
+            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
+            Type.SIDE_A);
+    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
+
+    DiffInfoCreator diffInfoCreator =
+        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
+    return diffInfoCreator.create(ps, sideA, sideB);
+  }
+
+  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
+
+    @Override
+    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(Type fileInfoType) {
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index ece8c68..66ccef3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -66,6 +66,7 @@
     boolean close = true;
     try {
       final RevWalk rw = new RevWalk(repo);
+      BinaryResult bin = null;
       try {
         final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
         RevCommit[] parents = commit.getParents();
@@ -77,7 +78,7 @@
         final RevCommit base = parents[0];
         rw.parseBody(base);
 
-        BinaryResult bin =
+        bin =
             new BinaryResult() {
               @Override
               public void writeTo(OutputStream out) throws IOException {
@@ -135,6 +136,9 @@
       } finally {
         if (close) {
           rw.close();
+          if (bin != null) {
+            bin.close();
+          }
         }
       }
     } finally {
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index a846d50..6eff9b0 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -51,21 +53,26 @@
 
 @Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final RelatedChangesSorter sorter;
   private final IndexConfig indexConfig;
+  private final ChangeData.Factory changeDataFactory;
 
   @Inject
   GetRelated(
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       RelatedChangesSorter sorter,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      ChangeData.Factory changeDataFactory) {
     this.queryProvider = queryProvider;
     this.psUtil = psUtil;
     this.sorter = sorter;
     this.indexConfig = indexConfig;
+    this.changeDataFactory = changeDataFactory;
   }
 
   @Override
@@ -80,6 +87,7 @@
   private List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
       throws IOException, PermissionBackendException {
     Set<String> groups = getAllGroups(rsrc.getNotes(), psUtil);
+    logger.atFine().log("groups = %s", groups);
     if (groups.isEmpty()) {
       return Collections.emptyList();
     }
@@ -97,8 +105,9 @@
 
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
+    logger.atFine().log("isEdit = %s, basePs = %s", isEdit, basePs);
 
-    reloadChangeIfStale(cds, basePs);
+    cds = reloadChangeIfStale(cds, rsrc.getChange(), basePs);
 
     for (RelatedChangesSorter.PatchSetData d : sorter.sort(cds, basePs)) {
       PatchSet ps = d.patchSet();
@@ -107,6 +116,9 @@
         // Replace base of an edit with the edit itself.
         ps = rsrc.getPatchSet();
         commit = rsrc.getEdit().get().getEditCommit();
+        logger.atFine().log(
+            "Replaced base of edit (patch set %s, commit %s) with edit (patch set %s, commit %s)",
+            d.patchSet().id(), d.commit(), ps.id(), commit);
       } else {
         commit = d.commit();
       }
@@ -127,14 +139,30 @@
     return psUtil.byChange(notes).stream().flatMap(ps -> ps.groups().stream()).collect(toSet());
   }
 
-  private void reloadChangeIfStale(List<ChangeData> cds, PatchSet wantedPs) {
-    for (ChangeData cd : cds) {
-      if (cd.getId().equals(wantedPs.id().changeId())) {
-        if (cd.patchSet(wantedPs.id()) == null) {
-          cd.reloadChange();
-        }
-      }
+  private List<ChangeData> reloadChangeIfStale(
+      List<ChangeData> changeDatasFromIndex, Change wantedChange, PatchSet wantedPs) {
+    checkArgument(
+        wantedChange.getId().equals(wantedPs.id().changeId()),
+        "change of wantedPs (%s) doesn't match wantedChange (%s)",
+        wantedPs.id().changeId(),
+        wantedChange.getId());
+
+    List<ChangeData> changeDatas = new ArrayList<>(changeDatasFromIndex.size() + 1);
+    changeDatas.addAll(changeDatasFromIndex);
+
+    // Reload the change in case the patch set is absent.
+    changeDatas.stream()
+        .filter(
+            cd -> cd.getId().equals(wantedPs.id().changeId()) && cd.patchSet(wantedPs.id()) == null)
+        .forEach(ChangeData::reloadChange);
+
+    if (changeDatas.stream().noneMatch(cd -> cd.getId().equals(wantedPs.id().changeId()))) {
+      // The change of the wanted patch set is missing in the result from the index.
+      // Load it from NoteDb and add it to the result.
+      changeDatas.add(changeDataFactory.create(wantedChange));
     }
+
+    return changeDatas;
   }
 
   static RelatedChangeAndCommitInfo newChangeAndCommit(
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
index 25cf311..a049e54 100644
--- a/java/com/google/gerrit/server/restapi/change/Ignore.java
+++ b/java/com/google/gerrit/server/restapi/change/Ignore.java
@@ -60,7 +60,7 @@
       if (!isIgnored(rsrc)) {
         stars.ignore(rsrc);
       }
-      return Response.ok("");
+      return Response.ok();
     } catch (MutuallyExclusiveLabelsException e) {
       throw new ResourceConflictException(e.getMessage());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Index.java b/java/com/google/gerrit/server/restapi/change/Index.java
index 5a17c07..5e17ae8 100644
--- a/java/com/google/gerrit/server/restapi/change/Index.java
+++ b/java/com/google/gerrit/server/restapi/change/Index.java
@@ -17,33 +17,29 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
-public class Index extends RetryingRestModifyView<ChangeResource, Input, Object> {
+public class Index implements RestModifyView<ChangeResource, Input> {
   private final PermissionBackend permissionBackend;
   private final ChangeIndexer indexer;
 
   @Inject
-  Index(RetryHelper retryHelper, PermissionBackend permissionBackend, ChangeIndexer indexer) {
-    super(retryHelper);
+  Index(PermissionBackend permissionBackend, ChangeIndexer indexer) {
     this.permissionBackend = permissionBackend;
     this.indexer = indexer;
   }
 
   @Override
-  protected Response<Object> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+  public Response<Object> apply(ChangeResource rsrc, Input input)
       throws IOException, AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.MAINTAIN_SERVER);
     indexer.index(rsrc.getChange());
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index bfc9f12..099d0a6 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -32,18 +32,19 @@
 @Singleton
 public class ListChangeMessages implements RestReadView<ChangeResource> {
   private final ChangeMessagesUtil changeMessagesUtil;
-  private final AccountLoader accountLoader;
+  private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
   public ListChangeMessages(
       ChangeMessagesUtil changeMessagesUtil, AccountLoader.Factory accountLoaderFactory) {
     this.changeMessagesUtil = changeMessagesUtil;
-    this.accountLoader = accountLoaderFactory.create(true);
+    this.accountLoaderFactory = accountLoaderFactory;
   }
 
   @Override
   public Response<List<ChangeMessageInfo>> apply(ChangeResource resource)
       throws PermissionBackendException {
+    AccountLoader accountLoader = accountLoaderFactory.create(true);
     List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
         messages.stream()
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
index 4c942d2..fa4555b 100644
--- a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
@@ -54,7 +54,7 @@
   public Response<String> apply(ChangeResource rsrc, Input input)
       throws RestApiException, IllegalLabelException {
     stars.markAsReviewed(rsrc);
-    return Response.ok("");
+    return Response.ok();
   }
 
   private boolean isReviewed(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
index 5945b14..601fc4a 100644
--- a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
@@ -52,7 +52,7 @@
   @Override
   public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
     stars.markAsUnreviewed(rsrc);
-    return Response.ok("");
+    return Response.ok();
   }
 
   private boolean isReviewed(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 9b17ed6..cce8923 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -133,10 +132,10 @@
     return Response.ok(result);
   }
 
-  private SubmitType getSubmitType(ChangeData cd) {
+  private SubmitType getSubmitType(ChangeData cd) throws ResourceConflictException {
     SubmitTypeRecord rec = submitRuleEvaluator.getSubmitType(cd);
     if (rec.status != SubmitTypeRecord.Status.OK) {
-      throw new StorageException("Submit type rule failed: " + rec);
+      throw new ResourceConflictException("submit type rule error: " + rec.errorMessage);
     }
     return rec.type;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index a57bd64..e339c67 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.SetAssigneeOp;
+import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
@@ -96,6 +97,7 @@
     post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
     post(CHANGE_KIND, "restore").to(Restore.class);
     post(CHANGE_KIND, "revert").to(Revert.class);
+    post(CHANGE_KIND, "revert_submission").to(RevertSubmission.class);
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
@@ -159,6 +161,7 @@
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
     child(REVISION_KIND, "fixes").to(Fixes.class);
     post(FIX_KIND, "apply").to(ApplyFix.class);
+    get(FIX_KIND, "preview").to(GetFixPreview.class);
 
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
@@ -199,6 +202,7 @@
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
+    factory(SetCherryPickOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 7d4c4d1..51c512f 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -59,8 +60,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -76,11 +75,11 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class Move extends RetryingRestModifyView<ChangeResource, MoveInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
+public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -93,17 +92,17 @@
   @Inject
   Move(
       PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
       ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ProjectCache projectCache,
       @GerritServerConfig Config gerritConfig) {
-    super(retryHelper);
     this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
     this.json = json;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
@@ -115,8 +114,7 @@
   }
 
   @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
+  public Response<ChangeInfo> apply(ChangeResource rsrc, MoveInput input)
       throws RestApiException, UpdateException, PermissionBackendException, IOException {
     if (!moveEnabled) {
       // This will be removed with the above config once we reach consensus for the move change
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index 516dead..c1a6a13 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -18,14 +18,13 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -33,19 +32,18 @@
 
 @Singleton
 public class PostHashtags
-    extends RetryingRestModifyView<ChangeResource, HashtagsInput, ImmutableSortedSet<String>>
-    implements UiAction<ChangeResource> {
+    implements RestModifyView<ChangeResource, HashtagsInput>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
 
   @Inject
-  PostHashtags(RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
-    super(retryHelper);
+  PostHashtags(BatchUpdate.Factory updateFactory, SetHashtagsOp.Factory hashtagsFactory) {
+    this.updateFactory = updateFactory;
     this.hashtagsFactory = hashtagsFactory;
   }
 
   @Override
-  protected Response<ImmutableSortedSet<String>> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, HashtagsInput input)
+  public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
       throws RestApiException, UpdateException, PermissionBackendException {
     req.permissions().check(ChangePermission.EDIT_HASHTAGS);
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index f008df3..f774457 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -18,11 +18,13 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetPrivateOp;
@@ -30,8 +32,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -39,27 +39,27 @@
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class PostPrivate extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, String>
-    implements UiAction<ChangeResource> {
+public class PostPrivate
+    implements RestModifyView<ChangeResource, InputWithMessage>, UiAction<ChangeResource> {
   private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
   private final boolean disablePrivateChanges;
 
   @Inject
   PostPrivate(
-      RetryHelper retryHelper,
       PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
       SetPrivateOp.Factory setPrivateOpFactory,
       @GerritServerConfig Config config) {
-    super(retryHelper);
     this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
     this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
   }
 
   @Override
-  public Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+  public Response<String> apply(ChangeResource rsrc, InputWithMessage input)
       throws RestApiException, UpdateException {
     if (disablePrivateChanges) {
       throw new MethodNotAllowedException("private changes are disabled");
@@ -70,7 +70,7 @@
     }
 
     if (rsrc.getChange().isPrivate()) {
-      return Response.ok("");
+      return Response.ok();
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(true, input);
@@ -79,7 +79,7 @@
       u.addOp(rsrc.getId(), op).execute();
     }
 
-    return Response.created("");
+    return Response.created();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 25a686d..129df59 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -73,12 +73,13 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -120,16 +121,12 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.nio.charset.StandardCharsets;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -141,7 +138,6 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.OptionalInt;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -150,8 +146,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
-public class PostReview
-    extends RetryingRestModifyView<RevisionResource, ReviewInput, ReviewResult> {
+public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
@@ -160,9 +155,7 @@
 
   public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
 
-  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
-  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
-
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
@@ -177,7 +170,6 @@
   private final ReviewerAdder reviewerAdder;
   private final AddReviewersEmail addReviewersEmail;
   private final NotifyResolver notifyResolver;
-  private final Config gerritConfig;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
@@ -186,7 +178,7 @@
 
   @Inject
   PostReview(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -206,7 +198,7 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       PluginSetContext<CommentValidator> commentValidators) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.commentsUtil = commentsUtil;
@@ -221,7 +213,6 @@
     this.reviewerAdder = reviewerAdder;
     this.addReviewersEmail = addReviewersEmail;
     this.notifyResolver = notifyResolver;
-    this.gerritConfig = gerritConfig;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
@@ -230,15 +221,13 @@
   }
 
   @Override
-  protected Response<ReviewResult> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
-    return apply(updateFactory, revision, input, TimeUtil.nowTs());
+    return apply(revision, input, TimeUtil.nowTs());
   }
 
-  public Response<ReviewResult> apply(
-      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
     // Respect timestamp, but truncate at change created-on time.
@@ -249,6 +238,8 @@
     ProjectState projectState = projectCache.checkedGet(revision.getProject());
     LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes());
 
+    logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled"));
+
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     logger.atFine().log("draft handling = %s", input.drafts);
 
@@ -270,6 +261,7 @@
     if (input.notify == null) {
       input.notify = defaultNotify(revision.getChange(), input);
     }
+    logger.atFine().log("notify handling = %s", input.notify);
 
     Map<String, AddReviewerResult> reviewerJsonResults = null;
     List<ReviewerAddition> reviewerResults = Lists.newArrayList();
@@ -282,13 +274,18 @@
             reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
+          logger.atFine().log(
+              "Adding %s as reviewer failed: %s", reviewerInput.reviewer, result.result.error);
           hasError = true;
           continue;
         }
         if (result.result.confirm != null) {
+          logger.atFine().log(
+              "Adding %s as reviewer requires confirmation", reviewerInput.reviewer);
           confirm = true;
           continue;
         }
+        logger.atFine().log("Adding %s as reviewer was prepared", reviewerInput.reviewer);
         reviewerResults.add(result);
       }
     }
@@ -307,6 +304,9 @@
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
         ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+        if (ccOrReviewer) {
+          logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
+        }
       }
 
       if (!ccOrReviewer) {
@@ -314,17 +314,22 @@
         ReviewerSet currentReviewers =
             approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
         ccOrReviewer = currentReviewers.all().contains(id);
+        if (ccOrReviewer) {
+          logger.atFine().log("calling user is already cc/reviewer on the change");
+        }
       }
 
       // Apply reviewer changes first. Revision emails should be sent to the
       // updated set of reviewers. Also keep track of whether the user added
       // themselves as a reviewer or to the CC list.
+      logger.atFine().log("adding reviewer additions");
       for (ReviewerAddition reviewerResult : reviewerResults) {
         reviewerResult.op.suppressEmail(); // Send a single batch email below.
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
         if (!ccOrReviewer && reviewerResult.result.reviewers != null) {
           for (ReviewerInfo reviewerInfo : reviewerResult.result.reviewers) {
             if (Objects.equals(id.get(), reviewerInfo._accountId)) {
+              logger.atFine().log("calling user is explicitly added as reviewer");
               ccOrReviewer = true;
               break;
             }
@@ -333,6 +338,7 @@
         if (!ccOrReviewer && reviewerResult.result.ccs != null) {
           for (AccountInfo accountInfo : reviewerResult.result.ccs) {
             if (Objects.equals(id.get(), accountInfo._accountId)) {
+              logger.atFine().log("calling user is explicitly added as cc");
               ccOrReviewer = true;
               break;
             }
@@ -344,6 +350,7 @@
         // User posting this review isn't currently in the reviewer or CC list,
         // isn't being explicitly added, and isn't voting on any label.
         // Automatically CC them on this change so they receive replies.
+        logger.atFine().log("CCing calling user");
         ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
         selfAddition.op.suppressEmail();
         bu.addOp(revision.getChange().getId(), selfAddition.op);
@@ -365,6 +372,7 @@
           output.ready = true;
         }
 
+        logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
         WorkInProgressOp wipOp =
             workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
         wipOp.suppressEmail();
@@ -372,6 +380,7 @@
       }
 
       // Add the review op.
+      logger.atFine().log("posting review");
       bu.addOp(
           revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
@@ -455,6 +464,8 @@
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
           PermissionBackendException, IOException, ConfigInvalidException {
+    logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
+
     if (in.labels == null || in.labels.isEmpty()) {
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
@@ -463,6 +474,8 @@
       throw new AuthException("not allowed to modify other user's drafts");
     }
 
+    logger.atFine().log("label input: %s", in.labels);
+
     CurrentUser caller = rev.getUser();
     PermissionBackend.ForChange perm = rev.permissions();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
@@ -470,15 +483,22 @@
       Map.Entry<String, Short> ent = itr.next();
       LabelType type = labelTypes.byLabel(ent.getKey());
       if (type == null) {
+        logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\" is not a configured label", ent.getKey()));
         }
+        logger.atFine().log("ignoring input for unknown label %s", ent.getKey());
         itr.remove();
         continue;
       }
 
-      if (!caller.isInternalUser()) {
+      if (caller.isInternalUser()) {
+        logger.atFine().log(
+            "skipping on behalf of permission check for label %s"
+                + " because caller is an internal user",
+            type.getName());
+      } else {
         try {
           perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
         } catch (AuthException e) {
@@ -491,11 +511,13 @@
       }
     }
     if (in.labels.isEmpty()) {
+      logger.atFine().log("labels are empty after unknown labels have been removed");
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
     IdentifiedUser reviewer = accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
+    logger.atFine().log("on behalf of user was resolved to %s", reviewer.getLoggableName());
     try {
       permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
@@ -509,16 +531,20 @@
 
   private void checkLabels(RevisionResource rsrc, LabelTypes labelTypes, Map<String, Short> labels)
       throws BadRequestException, AuthException, PermissionBackendException {
+    logger.atFine().log("checking label input: %s", labels);
+
     PermissionBackend.ForChange perm = rsrc.permissions();
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
       LabelType lt = labelTypes.byLabel(ent.getKey());
       if (lt == null) {
+        logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\" is not a configured label", ent.getKey()));
         }
+        logger.atFine().log("ignoring input for unknown label %s", ent.getKey());
         itr.remove();
         continue;
       }
@@ -530,10 +556,13 @@
       }
 
       if (lt.getValue(ent.getValue()) == null) {
+        logger.atFine().log("label value %s not found", ent.getValue());
         if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
         }
+        logger.atFine().log(
+            "ignoring input for label %s because label value is unknown", ent.getKey());
         itr.remove();
         continue;
       }
@@ -577,6 +606,7 @@
   private <T extends CommentInput> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
+    logger.atFine().log("checking comments");
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
@@ -629,44 +659,19 @@
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
       throws BadRequestException, PatchListNotAvailableException {
+    logger.atFine().log("checking robot comments");
     for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
       String commentPath = e.getKey();
       for (RobotCommentInput c : e.getValue()) {
-        ensureSizeOfJsonInputIsWithinBounds(c);
         ensureRobotIdIsSet(c.robotId, commentPath);
         ensureRobotRunIdIsSet(c.robotRunId, commentPath);
         ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
+        // Size is validated later, in CommentLimitsValidator.
       }
     }
     checkComments(revision, in);
   }
 
-  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
-      throws BadRequestException {
-    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
-    if (robotCommentSizeLimit.isPresent()) {
-      int sizeLimit = robotCommentSizeLimit.getAsInt();
-      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
-      int robotCommentSize = robotCommentBytes.length;
-      if (robotCommentSize > sizeLimit) {
-        throw new BadRequestException(
-            String.format(
-                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
-                robotCommentSize, sizeLimit));
-      }
-    }
-  }
-
-  private OptionalInt getRobotCommentSizeLimit() {
-    int robotCommentSizeLimit =
-        gerritConfig.getInt(
-            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
-    if (robotCommentSizeLimit <= 0) {
-      return OptionalInt.empty();
-    }
-    return OptionalInt.of(robotCommentSizeLimit);
-  }
-
   private static void ensureRobotIdIsSet(String robotId, String commentPath)
       throws BadRequestException {
     if (robotId == null) {
@@ -870,8 +875,10 @@
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getNotes(), psId);
-      boolean dirty = insertComments(ctx);
-      dirty |= insertRobotComments(ctx);
+      List<RobotComment> newRobotComments =
+          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+      boolean dirty = insertComments(ctx, newRobotComments);
+      dirty |= insertRobotComments(ctx, newRobotComments);
       dirty |= updateLabels(projectState, ctx);
       dirty |= insertMessage(ctx);
       return dirty;
@@ -898,7 +905,7 @@
           ctx.getWhen());
     }
 
-    private boolean insertComments(ChangeContext ctx)
+    private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
         throws UnprocessableEntityException, PatchListNotAvailableException,
             CommentsRejectedException {
       Map<String, List<CommentInput>> inputComments = in.comments;
@@ -961,16 +968,22 @@
         }
       }
 
+      CommentValidationContext commentValidationCtx =
+          CommentValidationContext.create(
+              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
       switch (in.drafts) {
         case PUBLISH:
         case PUBLISH_ALL_REVISIONS:
-          validateComments(Streams.concat(drafts.values().stream(), toPublish.stream()));
-          publishCommentUtil.publish(ctx, psId, drafts.values(), in.tag);
+          validateComments(
+              commentValidationCtx,
+              Streams.concat(
+                  drafts.values().stream(), toPublish.stream(), newRobotComments.stream()));
+          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), drafts.values(), in.tag);
           comments.addAll(drafts.values());
           break;
         case KEEP:
-        default:
-          validateComments(toPublish.stream());
+          validateComments(
+              commentValidationCtx, Stream.concat(toPublish.stream(), newRobotComments.stream()));
           break;
       }
       ChangeUpdate changeUpdate = ctx.getUpdate(psId);
@@ -979,30 +992,33 @@
       return !toPublish.isEmpty();
     }
 
-    private void validateComments(Stream<Comment> comments) throws CommentsRejectedException {
+    private void validateComments(CommentValidationContext ctx, Stream<Comment> comments)
+        throws CommentsRejectedException {
       ImmutableList<CommentForValidation> draftsForValidation =
           comments
               .map(
                   comment ->
                       CommentForValidation.create(
+                          comment instanceof RobotComment
+                              ? CommentForValidation.CommentSource.ROBOT
+                              : CommentForValidation.CommentSource.HUMAN,
                           comment.lineNbr > 0
                               ? CommentForValidation.CommentType.INLINE_COMMENT
                               : CommentForValidation.CommentType.FILE_COMMENT,
-                          comment.message))
+                          comment.message,
+                          comment.getApproximateSize()))
               .collect(toImmutableList());
       ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(commentValidators, draftsForValidation);
+          PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
       if (!draftValidationFailures.isEmpty()) {
         throw new CommentsRejectedException(draftValidationFailures);
       }
     }
 
-    private boolean insertRobotComments(ChangeContext ctx) throws PatchListNotAvailableException {
+    private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
       if (in.robotComments == null) {
         return false;
       }
-
-      List<RobotComment> newRobotComments = getNewRobotComments(ctx);
       commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
       comments.addAll(newRobotComments);
       return !newRobotComments.isEmpty();
@@ -1377,12 +1393,19 @@
         buf.append(String.format("\n\n(%d comments)", comments.size()));
       }
       if (!msg.isEmpty()) {
+        CommentValidationContext commentValidationCtx =
+            CommentValidationContext.create(
+                ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
         ImmutableList<CommentValidationFailure> messageValidationFailure =
             PublishCommentUtil.findInvalidComments(
+                commentValidationCtx,
                 commentValidators,
                 ImmutableList.of(
                     CommentForValidation.create(
-                        CommentForValidation.CommentType.CHANGE_MESSAGE, msg)));
+                        CommentForValidation.CommentSource.HUMAN,
+                        CommentForValidation.CommentType.CHANGE_MESSAGE,
+                        msg,
+                        msg.length())));
         if (!messageValidationFailure.isEmpty()) {
           throw new CommentsRejectedException(messageValidationFailure);
         }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index f74643c..e6a87e9 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerAdder;
@@ -29,8 +30,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestCollectionModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -40,28 +39,26 @@
 
 @Singleton
 public class PostReviewers
-    extends RetryingRestCollectionModifyView<
-        ChangeResource, ReviewerResource, AddReviewerInput, AddReviewerResult> {
-
+    implements RestCollectionModifyView<ChangeResource, ReviewerResource, AddReviewerInput> {
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeData.Factory changeDataFactory;
   private final NotifyResolver notifyResolver;
   private final ReviewerAdder reviewerAdder;
 
   @Inject
   PostReviewers(
+      BatchUpdate.Factory updateFactory,
       ChangeData.Factory changeDataFactory,
-      RetryHelper retryHelper,
       NotifyResolver notifyResolver,
       ReviewerAdder reviewerAdder) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.changeDataFactory = changeDataFactory;
     this.notifyResolver = notifyResolver;
     this.reviewerAdder = reviewerAdder;
   }
 
   @Override
-  protected Response<AddReviewerResult> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
+  public Response<AddReviewerResult> apply(ChangeResource rsrc, AddReviewerInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
     if (input.reviewer == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
index 44f35a0..d76e53a 100644
--- a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.edit.ChangeEdit;
@@ -28,8 +29,6 @@
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -38,27 +37,26 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PublishChangeEdit
-    extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Object> {
+public class PublishChangeEdit implements RestModifyView<ChangeResource, PublishChangeEditInput> {
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeEditUtil editUtil;
   private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreementsChecker;
 
   @Inject
   PublishChangeEdit(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       ChangeEditUtil editUtil,
       NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreementsChecker) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.editUtil = editUtil;
     this.notifyResolver = notifyResolver;
     this.contributorAgreementsChecker = contributorAgreementsChecker;
   }
 
   @Override
-  protected Response<Object> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
+  public Response<Object> apply(ChangeResource rsrc, PublishChangeEditInput in)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           NoSuchProjectException {
     contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index d4a14d4..7b0d905 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -38,8 +39,6 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -48,9 +47,10 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
-    implements UiAction<ChangeResource> {
+public class PutAssignee
+    implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
 
+  private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final SetAssigneeOp.Factory assigneeFactory;
   private final ReviewerAdder reviewerAdder;
@@ -60,14 +60,14 @@
 
   @Inject
   PutAssignee(
+      BatchUpdate.Factory updateFactory,
       AccountResolver accountResolver,
       SetAssigneeOp.Factory assigneeFactory,
-      RetryHelper retryHelper,
       ReviewerAdder reviewerAdder,
       AccountLoader.Factory accountLoaderFactory,
       PermissionBackend permissionBackend,
       ApprovalsUtil approvalsUtil) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.accountResolver = accountResolver;
     this.assigneeFactory = assigneeFactory;
     this.reviewerAdder = reviewerAdder;
@@ -77,8 +77,7 @@
   }
 
   @Override
-  protected Response<AccountInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
+  public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 451d010..f442a42 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -30,8 +31,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -39,21 +38,21 @@
 
 @Singleton
 public class PutDescription
-    extends RetryingRestModifyView<RevisionResource, DescriptionInput, String>
-    implements UiAction<RevisionResource> {
+    implements RestModifyView<RevisionResource, DescriptionInput>, UiAction<RevisionResource> {
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
 
   @Inject
-  PutDescription(ChangeMessagesUtil cmUtil, RetryHelper retryHelper, PatchSetUtil psUtil) {
-    super(retryHelper);
+  PutDescription(
+      BatchUpdate.Factory updateFactory, ChangeMessagesUtil cmUtil, PatchSetUtil psUtil) {
+    this.updateFactory = updateFactory;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
   }
 
   @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DescriptionInput input)
+  public Response<String> apply(RevisionResource rsrc, DescriptionInput input)
       throws UpdateException, RestApiException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
@@ -88,17 +87,21 @@
       if (oldDescription.equals(newDescription)) {
         return false;
       }
-      String summary;
-      if (oldDescription.isEmpty()) {
-        summary = "Description set to \"" + newDescription + "\"";
-      } else if (newDescription.isEmpty()) {
-        summary = "Description \"" + oldDescription + "\" removed";
-      } else {
-        summary = "Description changed to \"" + newDescription + "\"";
-      }
-
       update.setPsDescription(newDescription);
 
+      String summary;
+      if (oldDescription.isEmpty()) {
+        summary =
+            String.format("Description of patch set %d set to \"%s\"", psId.get(), newDescription);
+      } else if (newDescription.isEmpty()) {
+        summary =
+            String.format(
+                "Description \"%s\" removed from patch set %d", oldDescription, psId.get());
+      } else {
+        summary =
+            String.format(
+                "Description of patch set %d changed to \"%s\"", psId.get(), newDescription);
+      }
       ChangeMessage cmsg =
           ChangeMessagesUtil.newMessage(
               psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 5696fcb..63cd7a3 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -35,8 +36,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -47,9 +46,8 @@
 import java.util.Optional;
 
 @Singleton
-public class PutDraftComment
-    extends RetryingRestModifyView<DraftCommentResource, DraftInput, CommentInfo> {
-
+public class PutDraftComment implements RestModifyView<DraftCommentResource, DraftInput> {
+  private final BatchUpdate.Factory updateFactory;
   private final DeleteDraftComment delete;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -58,13 +56,13 @@
 
   @Inject
   PutDraftComment(
+      BatchUpdate.Factory updateFactory,
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      RetryHelper retryHelper,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -73,11 +71,10 @@
   }
 
   @Override
-  protected Response<CommentInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
+  public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in)
       throws RestApiException, UpdateException, PermissionBackendException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
-      return delete.applyImpl(updateFactory, rsrc, null);
+      return delete.apply(rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
       throw new BadRequestException("id must match URL");
     } else if (in.line != null && in.line < 0) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index acda547..4761d0c3 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -38,8 +39,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -61,8 +60,9 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class PutMessage extends RetryingRestModifyView<ChangeResource, CommitMessageInput, String> {
+public class PutMessage implements RestModifyView<ChangeResource, CommitMessageInput> {
 
+  private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
   private final TimeZone tz;
@@ -74,7 +74,7 @@
 
   @Inject
   PutMessage(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager repositoryManager,
       Provider<CurrentUser> userProvider,
       PatchSetInserter.Factory psInserterFactory,
@@ -83,7 +83,7 @@
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
       ProjectCache projectCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
@@ -95,8 +95,7 @@
   }
 
   @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource resource, CommitMessageInput input)
+  public Response<String> apply(ChangeResource resource, CommitMessageInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
     PatchSet ps = psUtil.current(resource.getNotes());
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index cfeb884..f673bfc 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -33,29 +34,27 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, String>
-    implements UiAction<ChangeResource> {
+public class PutTopic
+    implements RestModifyView<ChangeResource, TopicInput>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
   private final ChangeMessagesUtil cmUtil;
   private final TopicEdited topicEdited;
 
   @Inject
-  PutTopic(ChangeMessagesUtil cmUtil, RetryHelper retryHelper, TopicEdited topicEdited) {
-    super(retryHelper);
+  PutTopic(BatchUpdate.Factory updateFactory, ChangeMessagesUtil cmUtil, TopicEdited topicEdited) {
+    this.updateFactory = updateFactory;
     this.cmUtil = cmUtil;
     this.topicEdited = topicEdited;
   }
 
   @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, TopicInput input)
+  public Response<String> apply(ChangeResource req, TopicInput input)
       throws UpdateException, RestApiException, PermissionBackendException {
     req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
 
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 6e5f554..878e714 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -34,9 +34,11 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
 import org.kohsuke.args4j.Option;
 
@@ -45,8 +47,12 @@
 
   private final ChangeJson.Factory json;
   private final ChangeQueryBuilder qb;
-  private final ChangeQueryProcessor imp;
+  private final Provider<ChangeQueryProcessor> queryProcessorProvider;
+  private final HashMap<String, DynamicOptions.DynamicBean> dynamicBeans = new HashMap<>();
   private EnumSet<ListChangesOption> options;
+  private Integer limit;
+  private Integer start;
+  private Boolean noLimit;
 
   @Option(
       name = "--query",
@@ -61,7 +67,7 @@
       metaVar = "CNT",
       usage = "Maximum number of results to return")
   public void setLimit(int limit) {
-    imp.setUserProvidedLimit(limit);
+    this.limit = limit;
   }
 
   @Option(name = "-o", usage = "Output options per change")
@@ -80,24 +86,27 @@
       metaVar = "CNT",
       usage = "Number of changes to skip")
   public void setStart(int start) {
-    imp.setStart(start);
+    this.start = start;
   }
 
   @Option(name = "--no-limit", usage = "Return all results, overriding the default limit")
   public void setNoLimit(boolean on) {
-    imp.setNoLimit(on);
+    this.noLimit = on;
   }
 
   @Override
   public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
-    imp.setDynamicBean(plugin, dynamicBean);
+    dynamicBeans.put(plugin, dynamicBean);
   }
 
   @Inject
-  QueryChanges(ChangeJson.Factory json, ChangeQueryBuilder qb, ChangeQueryProcessor qp) {
+  QueryChanges(
+      ChangeJson.Factory json,
+      ChangeQueryBuilder qb,
+      Provider<ChangeQueryProcessor> queryProcessorProvider) {
     this.json = json;
     this.qb = qb;
-    this.imp = qp;
+    this.queryProcessorProvider = queryProcessorProvider;
 
     options = EnumSet.noneOf(ListChangesOption.class);
   }
@@ -129,9 +138,22 @@
   }
 
   private List<List<ChangeInfo>> query() throws QueryParseException, PermissionBackendException {
-    if (imp.isDisabled()) {
+    ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
+    if (queryProcessor.isDisabled()) {
       throw new QueryParseException("query disabled");
     }
+
+    if (limit != null) {
+      queryProcessor.setUserProvidedLimit(limit);
+    }
+    if (start != null) {
+      queryProcessor.setStart(start);
+    }
+    if (noLimit != null) {
+      queryProcessor.setNoLimit(noLimit);
+    }
+    dynamicBeans.forEach((p, b) -> queryProcessor.setDynamicBean(p, b));
+
     if (queries == null || queries.isEmpty()) {
       queries = Collections.singletonList("status:open");
     } else if (queries.size() > 10) {
@@ -141,9 +163,9 @@
     }
 
     int cnt = queries.size();
-    List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
+    List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(options, this.imp.getAttributesFactory()).format(results);
+        json.create(options, queryProcessor.getAttributesFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index af8f971..7a9136b 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -47,8 +47,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -63,13 +61,14 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class Rebase extends RetryingRestModifyView<RevisionResource, RebaseInput, ChangeInfo>
+public class Rebase
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
+  private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
   private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
@@ -80,7 +79,7 @@
 
   @Inject
   public Rebase(
-      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
       RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
@@ -88,7 +87,7 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
@@ -99,8 +98,7 @@
   }
 
   @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
     // Not allowed to rebase if the current patch set is locked.
     patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
@@ -260,28 +258,24 @@
     return description;
   }
 
-  public static class CurrentRevision
-      extends RetryingRestModifyView<ChangeResource, RebaseInput, ChangeInfo> {
+  public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
 
     @Inject
-    CurrentRevision(RetryHelper retryHelper, PatchSetUtil psUtil, Rebase rebase) {
-      super(retryHelper);
+    CurrentRevision(PatchSetUtil psUtil, Rebase rebase) {
       this.psUtil = psUtil;
       this.rebase = rebase;
     }
 
     @Override
-    protected Response<ChangeInfo> applyImpl(
-        BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws Exception {
+    public Response<ChangeInfo> apply(ChangeResource rsrc, RebaseInput input)
+        throws RestApiException, UpdateException, IOException, PermissionBackendException {
       PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       }
-      return Response.ok(
-          rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input).value());
+      return Response.ok(rebase.apply(new RevisionResource(rsrc, ps), input).value());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
index 7be8765..9fb8de8 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -19,37 +19,30 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class RebaseChangeEdit extends RetryingRestModifyView<ChangeResource, Input, Object> {
+public class RebaseChangeEdit implements RestModifyView<ChangeResource, Input> {
   private final GitRepositoryManager repositoryManager;
   private final ChangeEditModifier editModifier;
 
   @Inject
-  RebaseChangeEdit(
-      RetryHelper retryHelper,
-      GitRepositoryManager repositoryManager,
-      ChangeEditModifier editModifier) {
-    super(retryHelper);
+  RebaseChangeEdit(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
     this.repositoryManager = repositoryManager;
     this.editModifier = editModifier;
   }
 
   @Override
-  protected Response<Object> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input in)
+  public Response<Object> apply(ChangeResource rsrc, Input in)
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     Project.NameKey project = rsrc.getProject();
     try (Repository repository = repositoryManager.openRepository(project)) {
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
index 8040847..af65483 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -76,7 +77,15 @@
     // Map of all patch sets, keyed by commit SHA-1.
     Map<ObjectId, PatchSetData> byId = collectById(in);
     PatchSetData start = byId.get(startPs.commitId());
-    checkArgument(start != null, "%s not found in %s", startPs, in);
+    requireNonNull(
+        start,
+        () ->
+            String.format(
+                "commit %s of patch set %s not found in %s",
+                startPs.commitId().name(),
+                startPs.id(),
+                byId.entrySet().stream()
+                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 679d4f8..54575bb 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -43,8 +44,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -52,10 +51,11 @@
 import java.io.IOException;
 
 @Singleton
-public class Restore extends RetryingRestModifyView<ChangeResource, RestoreInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
+public class Restore
+    implements RestModifyView<ChangeResource, RestoreInput>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final BatchUpdate.Factory updateFactory;
   private final RestoredSender.Factory restoredSenderFactory;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
@@ -65,14 +65,14 @@
 
   @Inject
   Restore(
+      BatchUpdate.Factory updateFactory,
       RestoredSender.Factory restoredSenderFactory,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
-      RetryHelper retryHelper,
       ChangeRestored changeRestored,
       ProjectCache projectCache) {
-    super(retryHelper);
+    this.updateFactory = updateFactory;
     this.restoredSenderFactory = restoredSenderFactory;
     this.json = json;
     this.cmUtil = cmUtil;
@@ -82,8 +82,7 @@
   }
 
   @Override
-  protected Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, RestoreInput input)
+  public Response<ChangeInfo> apply(ChangeResource rsrc, RestoreInput input)
       throws RestApiException, UpdateException, PermissionBackendException, IOException {
     // Not allowed to restore if the current patch set is locked.
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index dad87e5..7ebb954 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -14,127 +14,70 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.CommitUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.HashSet;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class Revert extends RetryingRestModifyView<ChangeResource, RevertInput, ChangeInfo>
-    implements UiAction<ChangeResource> {
+public class Revert
+    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
-  private final GitRepositoryManager repoManager;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final ChangeMessagesUtil cmUtil;
-  private final Sequences seq;
   private final PatchSetUtil psUtil;
-  private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeJson.Factory json;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeReverted changeReverted;
   private final ContributorAgreementsChecker contributorAgreements;
   private final ProjectCache projectCache;
-  private final NotifyResolver notifyResolver;
   private final CommitUtil commitUtil;
 
   @Inject
   Revert(
       PermissionBackend permissionBackend,
-      GitRepositoryManager repoManager,
-      ChangeInserter.Factory changeInserterFactory,
-      ChangeMessagesUtil cmUtil,
-      RetryHelper retryHelper,
-      Sequences seq,
       PatchSetUtil psUtil,
-      RevertedSender.Factory revertedSenderFactory,
       ChangeJson.Factory json,
-      ApprovalsUtil approvalsUtil,
-      ChangeReverted changeReverted,
       ContributorAgreementsChecker contributorAgreements,
       ProjectCache projectCache,
-      NotifyResolver notifyResolver,
       CommitUtil commitUtil) {
-    super(retryHelper);
     this.permissionBackend = permissionBackend;
-    this.repoManager = repoManager;
-    this.changeInserterFactory = changeInserterFactory;
-    this.cmUtil = cmUtil;
-    this.seq = seq;
     this.psUtil = psUtil;
-    this.revertedSenderFactory = revertedSenderFactory;
     this.json = json;
-    this.approvalsUtil = approvalsUtil;
-    this.changeReverted = changeReverted;
     this.contributorAgreements = contributorAgreements;
     this.projectCache = projectCache;
-    this.notifyResolver = notifyResolver;
     this.commitUtil = commitUtil;
   }
 
   @Override
-  public Response<ChangeInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
+  public Response<ChangeInfo> apply(ChangeResource rsrc, RevertInput input)
       throws IOException, RestApiException, UpdateException, NoSuchChangeException,
           PermissionBackendException, NoSuchProjectException, ConfigInvalidException {
     Change change = rsrc.getChange();
@@ -145,70 +88,19 @@
     contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
     permissionBackend.user(rsrc.getUser()).ref(change.getDest()).check(CREATE_CHANGE);
     projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite();
-
-    Change.Id revertId = revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), input);
-    return Response.ok(json.noOptions().format(rsrc.getProject(), revertId));
-  }
-
-  private Change.Id revert(
-      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, RevertInput input)
-      throws IOException, RestApiException, UpdateException, ConfigInvalidException {
+    ChangeNotes notes = rsrc.getNotes();
     Change.Id changeIdToRevert = notes.getChangeId();
     PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
     PatchSet patch = psUtil.get(notes, patchSetId);
     if (patch == null) {
       throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
-
-    Project.NameKey project = notes.getProjectName();
-    try (Repository git = repoManager.openRepository(project);
-        ObjectInserter oi = git.newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk revWalk = new RevWalk(reader)) {
-
-      Timestamp now = TimeUtil.nowTs();
-      ObjectId generatedChangeId = Change.generateChangeId();
-      Change changeToRevert = notes.getChange();
-      ObjectId revertCommitId =
-          commitUtil.createRevertCommit(
-              input.message, notes, user, generatedChangeId, now, oi, revWalk);
-
-      RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
-
-      Change.Id changeId = Change.id(seq.nextChangeId());
-      NotifyResolver.Result notify =
-          notifyResolver.resolve(
-              firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
-
-      ChangeInserter ins =
-          changeInserterFactory
-              .create(changeId, revertCommit, notes.getChange().getDest().branch())
-              .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
-      ins.setMessage("Uploaded patch set 1.");
-
-      ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
-
-      Set<Account.Id> reviewers = new HashSet<>();
-      reviewers.add(changeToRevert.getOwner());
-      reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
-      reviewers.remove(user.getAccountId());
-      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
-      ccs.remove(user.getAccountId());
-      ins.setReviewersAndCcs(reviewers, ccs);
-      ins.setRevertOf(changeIdToRevert);
-
-      try (BatchUpdate bu = updateFactory.create(project, user, now)) {
-        bu.setRepository(git, revWalk, oi);
-        bu.setNotify(notify);
-        bu.insertChange(ins);
-        bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
-        bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(generatedChangeId));
-        bu.execute();
-      }
-      return changeId;
-    } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(changeIdToRevert.toString(), e);
-    }
+    Timestamp timestamp = TimeUtil.nowTs();
+    return Response.ok(
+        json.noOptions()
+            .format(
+                rsrc.getProject(),
+                commitUtil.createRevertChange(notes, rsrc.getUser(), input, timestamp)));
   }
 
   @Override
@@ -232,49 +124,4 @@
                     .ref(change.getDest())
                     .testCond(CREATE_CHANGE)));
   }
-
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final ChangeInserter ins;
-
-    NotifyOp(Change change, ChangeInserter ins) {
-      this.change = change;
-      this.ins = ins;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws Exception {
-      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
-      try {
-        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setNotify(ctx.getNotify(change.getId()));
-        cm.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for revert change %s", change.getId());
-      }
-    }
-  }
-
-  private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
-
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.name(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
new file mode 100644
index 0000000..8fc59a2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -0,0 +1,671 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RevertInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeMessages;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.WalkSorter;
+import com.google.gerrit.server.change.WalkSorter.PatchSetData;
+import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CherryPickChange.Result;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.apache.commons.lang.RandomStringUtils;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class RevertSubmission
+    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+  private final PatchSetUtil psUtil;
+  private final ContributorAgreementsChecker contributorAgreements;
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+  private final GitRepositoryManager repoManager;
+  private final WalkSorter sorter;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommitUtil commitUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeReverted changeReverted;
+  private final RevertedSender.Factory revertedSenderFactory;
+  private final Sequences seq;
+  private final NotifyResolver notifyResolver;
+  private final BatchUpdate.Factory updateFactory;
+
+  private CherryPickInput cherryPickInput;
+  private List<ChangeInfo> results;
+  private static final Pattern patternRevertSubject = Pattern.compile("Revert \"(.+)\"");
+  private static final Pattern patternRevertSubjectWithNum =
+      Pattern.compile("Revert\\^(\\d+) \"(.+)\"");
+
+  @Inject
+  RevertSubmission(
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      PatchSetUtil psUtil,
+      ContributorAgreementsChecker contributorAgreements,
+      CherryPickChange cherryPickChange,
+      ChangeJson.Factory json,
+      GitRepositoryManager repoManager,
+      WalkSorter sorter,
+      ChangeMessagesUtil cmUtil,
+      CommitUtil commitUtil,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeReverted changeReverted,
+      RevertedSender.Factory revertedSenderFactory,
+      Sequences seq,
+      NotifyResolver notifyResolver,
+      BatchUpdate.Factory updateFactory) {
+    this.queryProvider = queryProvider;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+    this.psUtil = psUtil;
+    this.contributorAgreements = contributorAgreements;
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+    this.repoManager = repoManager;
+    this.sorter = sorter;
+    this.cmUtil = cmUtil;
+    this.commitUtil = commitUtil;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeReverted = changeReverted;
+    this.revertedSenderFactory = revertedSenderFactory;
+    this.seq = seq;
+    this.notifyResolver = notifyResolver;
+    this.updateFactory = updateFactory;
+    results = new ArrayList<>();
+    cherryPickInput = null;
+  }
+
+  @Override
+  public Response<RevertSubmissionInfo> apply(ChangeResource changeResource, RevertInput input)
+      throws RestApiException, IOException, UpdateException, PermissionBackendException,
+          NoSuchProjectException, ConfigInvalidException, StorageException {
+
+    if (!changeResource.getChange().isMerged()) {
+      throw new ResourceConflictException(
+          String.format("change is %s.", ChangeUtil.status(changeResource.getChange())));
+    }
+
+    String submissionId = changeResource.getChange().getSubmissionId();
+    if (submissionId == null) {
+      throw new ResourceConflictException(
+          "This change is merged but doesn't have a submission id,"
+              + " meaning it was not submitted through Gerrit.");
+    }
+    List<ChangeData> changeDatas = queryProvider.get().bySubmissionId(submissionId);
+
+    checkPermissionsForAllChanges(changeResource, changeDatas);
+    input.topic = createTopic(input.topic, submissionId);
+    return Response.ok(revertSubmission(changeDatas, input));
+  }
+
+  private String createTopic(String topic, String submissionId) {
+    if (topic == null) {
+      return String.format(
+          "revert-%s-%s", submissionId, RandomStringUtils.randomAlphabetic(10).toUpperCase());
+    }
+    return topic;
+  }
+
+  private void checkPermissionsForAllChanges(
+      ChangeResource changeResource, List<ChangeData> changeDatas)
+      throws IOException, AuthException, PermissionBackendException, ResourceConflictException {
+    for (ChangeData changeData : changeDatas) {
+      Change change = changeData.change();
+
+      // Might do the permission tests multiple times, but these are necessary to ensure that the
+      // user has permissions to revert all changes. If they lack any permission, no revert will be
+      // done.
+
+      contributorAgreements.check(change.getProject(), changeResource.getUser());
+      permissionBackend.currentUser().ref(change.getDest()).check(CREATE_CHANGE);
+      permissionBackend.currentUser().change(changeData).check(ChangePermission.READ);
+      projectCache.checkedGet(change.getProject()).checkStatePermitsWrite();
+
+      requireNonNull(
+          psUtil.get(changeData.notes(), change.currentPatchSetId()),
+          String.format(
+              "current patch set %s of change %s not found",
+              change.currentPatchSetId(), change.currentPatchSetId()));
+    }
+  }
+
+  private RevertSubmissionInfo revertSubmission(
+      List<ChangeData> changeData, RevertInput revertInput)
+      throws RestApiException, IOException, UpdateException, ConfigInvalidException,
+          StorageException {
+
+    Multimap<BranchNameKey, ChangeData> changesPerProjectAndBranch = ArrayListMultimap.create();
+    changeData.stream().forEach(c -> changesPerProjectAndBranch.put(c.change().getDest(), c));
+    cherryPickInput = createCherryPickInput(revertInput);
+    Timestamp timestamp = TimeUtil.nowTs();
+
+    for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
+      cherryPickInput.base = null;
+      Project.NameKey project = projectAndBranch.project();
+      cherryPickInput.destination = projectAndBranch.branch();
+      Collection<ChangeData> changesInProjectAndBranch =
+          changesPerProjectAndBranch.get(projectAndBranch);
+
+      // Sort the changes topologically.
+      Iterator<PatchSetData> sortedChangesInProjectAndBranch =
+          sorter.sort(changesInProjectAndBranch).iterator();
+
+      Set<ObjectId> commitIdsInProjectAndBranch =
+          changesInProjectAndBranch.stream()
+              .map(c -> c.currentPatchSet().commitId())
+              .collect(Collectors.toSet());
+
+      revertAllChangesInProjectAndBranch(
+          revertInput,
+          project,
+          sortedChangesInProjectAndBranch,
+          commitIdsInProjectAndBranch,
+          timestamp);
+    }
+    results.sort(Comparator.comparing(c -> c.revertOf));
+    RevertSubmissionInfo revertSubmissionInfo = new RevertSubmissionInfo();
+    revertSubmissionInfo.revertChanges = results;
+    return revertSubmissionInfo;
+  }
+
+  private void revertAllChangesInProjectAndBranch(
+      RevertInput revertInput,
+      Project.NameKey project,
+      Iterator<PatchSetData> sortedChangesInProjectAndBranch,
+      Set<ObjectId> commitIdsInProjectAndBranch,
+      Timestamp timestamp)
+      throws IOException, RestApiException, UpdateException, ConfigInvalidException {
+
+    String groupName = null;
+    String initialMessage = revertInput.message;
+    while (sortedChangesInProjectAndBranch.hasNext()) {
+      ChangeNotes changeNotes = sortedChangesInProjectAndBranch.next().data().notes();
+      if (cherryPickInput.base == null) {
+        cherryPickInput.base = getBase(changeNotes, commitIdsInProjectAndBranch).name();
+      }
+
+      revertInput.message = getMessage(initialMessage, changeNotes);
+      if (cherryPickInput.base.equals(changeNotes.getCurrentPatchSet().commitId().getName())) {
+        // This is the code in case this is the first revert of this project + branch, and the
+        // revert would be on top of the change being reverted.
+        craeteNormalRevert(revertInput, changeNotes, timestamp);
+        groupName = cherryPickInput.base;
+      } else {
+        // This is the code in case this is the second revert (or more) of this project + branch.
+        if (groupName == null) {
+          groupName = cherryPickInput.base;
+        }
+        createCherryPickedRevert(revertInput, project, groupName, changeNotes, timestamp);
+      }
+    }
+  }
+
+  private void createCherryPickedRevert(
+      RevertInput revertInput,
+      Project.NameKey project,
+      String groupName,
+      ChangeNotes changeNotes,
+      Timestamp timestamp)
+      throws IOException, ConfigInvalidException, UpdateException, RestApiException {
+    ObjectId revCommitId =
+        commitUtil.createRevertCommit(revertInput.message, changeNotes, user.get(), timestamp);
+    // TODO (paiking): As a future change, the revert should just be done directly on the
+    // target rather than just creating a commit and then cherry-picking it.
+    cherryPickInput.message = revertInput.message;
+    ObjectId generatedChangeId = Change.generateChangeId();
+    Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
+    // TODO (paiking): In the the future, the timestamp should be the same for all the revert
+    // changes.
+    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      bu.setNotify(
+          notifyResolver.resolve(
+              firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
+              cherryPickInput.notifyDetails));
+      bu.addOp(
+          changeNotes.getChange().getId(),
+          new CreateCherryPickOp(
+              revCommitId,
+              revertInput.topic,
+              generatedChangeId,
+              cherryPickRevertChangeId,
+              groupName,
+              timestamp));
+      bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
+      bu.addOp(
+          cherryPickRevertChangeId,
+          new NotifyOp(changeNotes.getChange(), cherryPickRevertChangeId));
+
+      bu.execute();
+    }
+  }
+
+  private void craeteNormalRevert(
+      RevertInput revertInput, ChangeNotes changeNotes, Timestamp timestamp)
+      throws IOException, RestApiException, UpdateException, ConfigInvalidException {
+
+    Change.Id revertId =
+        commitUtil.createRevertChange(changeNotes, user.get(), revertInput, timestamp);
+    results.add(json.noOptions().format(changeNotes.getProjectName(), revertId));
+    cherryPickInput.base =
+        changeNotesFactory.createChecked(revertId).getCurrentPatchSet().commitId().getName();
+  }
+
+  private CherryPickInput createCherryPickInput(RevertInput revertInput) {
+    cherryPickInput = new CherryPickInput();
+    // To create a revert change, we create a revert commit that is then cherry-picked. The revert
+    // change is created for the cherry-picked commit. Notifications are sent only for this change,
+    // but not for the intermediately created revert commit.
+    cherryPickInput.notify = revertInput.notify;
+    cherryPickInput.notifyDetails = revertInput.notifyDetails;
+    cherryPickInput.parent = 1;
+    cherryPickInput.keepReviewers = true;
+    return cherryPickInput;
+  }
+
+  private String getMessage(String initialMessage, ChangeNotes changeNotes) {
+    String subject = changeNotes.getChange().getSubject();
+    if (subject.length() > 60) {
+      subject = subject.substring(0, 56) + "...";
+    }
+    if (initialMessage == null) {
+      initialMessage =
+          MessageFormat.format(
+              ChangeMessages.get().revertSubmissionDefaultMessage,
+              changeNotes.getCurrentPatchSet().commitId().name());
+    }
+
+    // For performance purposes: Almost all cases will end here.
+    if (!subject.startsWith("Revert")) {
+      return MessageFormat.format(
+          ChangeMessages.get().revertSubmissionUserMessage, subject, initialMessage);
+    }
+
+    Matcher matcher = patternRevertSubjectWithNum.matcher(subject);
+
+    if (matcher.matches()) {
+      return MessageFormat.format(
+          ChangeMessages.get().revertSubmissionOfRevertSubmissionUserMessage,
+          Integer.valueOf(matcher.group(1)) + 1,
+          matcher.group(2),
+          changeNotes.getCurrentPatchSet().commitId().name());
+    }
+
+    matcher = patternRevertSubject.matcher(subject);
+    if (matcher.matches()) {
+      return MessageFormat.format(
+          ChangeMessages.get().revertSubmissionOfRevertSubmissionUserMessage,
+          2,
+          matcher.group(1),
+          changeNotes.getCurrentPatchSet().commitId().name());
+    }
+
+    return MessageFormat.format(
+        ChangeMessages.get().revertSubmissionUserMessage, subject, initialMessage);
+  }
+
+  /**
+   * This function finds the base that the first revert in a project + branch should be based on. It
+   * searches using BFS for the first commit that is either: 1. Has 2 or more parents, and has as
+   * parents at least one commit that is part of the submission. 2. A commit that is part of the
+   * submission. If neither of those are true, it just continues the search by going to the parents.
+   *
+   * <p>If 1 is true, if all the parents are part of the submission, it just returns that commit. If
+   * only some of them are in the submission, the function changes and starts checking only the
+   * commits that are not part of the submission. If all of them are part of the submission (or they
+   * are also merge commits that have as parents only other merge commits, or other changes that are
+   * part of the submission), we will return possibleMergeCommitToReturn which is the original
+   * commit we started with when 1 was determined to be true.
+   *
+   * <p>If 2 is true, it will return the commit that WalkSorter has decided that it should be the
+   * first commit reverted (e.g changeNotes, which is also the commit that is the first in the
+   * topological sorting). Unless possibleMergeCommitToReturn is not null, which means we already
+   * encountered a merge commit with a part of the submission earlier, which means we should return
+   * that merge commit.
+   *
+   * <p>It doesn't run through the entire graph since it will stop once it finds at least one commit
+   * that is part of the submission.
+   *
+   * @param changeNotes changeNotes for the change that is found by WalkSorter to be the first one
+   *     that should be reverted, the first in the topological sorting.
+   * @param commitIds The commitIds of this project and branch.
+   * @return the base of the first revert.
+   */
+  private ObjectId getBase(ChangeNotes changeNotes, Set<ObjectId> commitIds)
+      throws StorageException, IOException {
+    try (Repository git = repoManager.openRepository(changeNotes.getProjectName());
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+
+      ObjectId startCommit =
+          git.getRefDatabase().findRef(changeNotes.getChange().getDest().branch()).getObjectId();
+      Queue<ObjectId> commitsToSearch = new ArrayDeque<>();
+      commitsToSearch.add(startCommit);
+
+      while (!commitsToSearch.isEmpty()) {
+
+        RevCommit revCommit = revWalk.parseCommit(commitsToSearch.poll());
+        if (commitIds.contains(revCommit.getId())) {
+          return changeNotes.getCurrentPatchSet().commitId();
+        }
+        if (revCommit.getParentCount() > 1) {
+          List<RevCommit> parentsInSubmission =
+              Arrays.stream(revCommit.getParents())
+                  .filter(parent -> commitIds.contains(parent.getId()))
+                  .collect(Collectors.toList());
+          if (parentsInSubmission.size() > 1) {
+            // Found a merge commit that has multiple parent commits that are part of the
+            // submission.
+            return revCommit.getId();
+          }
+          if (!parentsInSubmission.isEmpty()) {
+            // Found a merge commit that has only one parent in this submission, but also other
+            // parents not in the submission. Now we need to check if the others are merge commits
+            // that have as parents only other merge commits, or other changes from the
+            // submission.
+            commitsToSearch.clear();
+            commitsToSearch.addAll(
+                Arrays.stream(revCommit.getParents())
+                    .filter(parent -> !commitIds.contains(parent.getId()))
+                    .collect(Collectors.toList()));
+
+            if (isMergeCommitDescendantOfAllChangesInTheProjectAndBranchOfTheSubmission(
+                commitsToSearch, commitIds, revWalk, revCommit, changeNotes)) {
+              // Found a second commit of that submission that share the same merge commit.
+              return revCommit.getId();
+            }
+            // Couldn't find a second commit of that submission that share the same merge commit.
+            return changeNotes.getCurrentPatchSet().commitId();
+          }
+        }
+        commitsToSearch.addAll(Arrays.asList(revCommit.getParents()));
+      }
+      // This should never happen since it can only happen if we go through the entire repository
+      // without finding a single commit that matches any commit from the submission.
+      throw new StorageException(
+          String.format(
+              "Couldn't find change %s in the repository %s",
+              changeNotes.getChangeId(), changeNotes.getProjectName().get()));
+    }
+  }
+
+  /**
+   * This helper function calculates whether or not there exists a second commit that is part of the
+   * submission and ancestor of the same merge commit.
+   *
+   * @param commitsToSearch Starts as all the parents of the potential merge commit, except the one
+   *     that is part of the submission.
+   * @param commitIds The commitIds of this project and branch.
+   * @param revWalk Used for walking through the revisions.
+   * @param potentialCommitToReturn The merge commit that is potentially a descendant of all changes
+   *     in the project and branch of the submission.
+   * @param changeNotes changeNotes for the change that is found by WalkSorter to be the first one
+   *     that should be reverted, the first in the topological sorting.
+   * @return True if found another commit of this submission, false if not found.
+   */
+  private boolean isMergeCommitDescendantOfAllChangesInTheProjectAndBranchOfTheSubmission(
+      Queue<ObjectId> commitsToSearch,
+      Set<ObjectId> commitIds,
+      RevWalk revWalk,
+      RevCommit potentialCommitToReturn,
+      ChangeNotes changeNotes)
+      throws StorageException, IOException {
+    while (!commitsToSearch.isEmpty()) {
+      RevCommit revCommit = revWalk.parseCommit(commitsToSearch.poll());
+      if (revCommit.getParentCount() > 1) {
+        List<RevCommit> parents = Arrays.asList(revCommit.getParents());
+        if (parents.stream().anyMatch(p -> commitIds.contains(p.getId()))) {
+          // found another commit with a common descendant.
+          return true;
+        }
+        Arrays.asList(revCommit.getParents()).forEach(parent -> commitsToSearch.add(parent));
+      } else {
+        // We found a merge commit, we found that one of the parents is not a merge commit nor a
+        // change of this submission. Therefore the merge commit is not useful, and we just
+        // rebase on the most recent revert as usual.
+        return false;
+      }
+    }
+    // This should never happen since it can only happen if we go through the entire repository
+    // encountering only merge commits after encountering the change whose submission we are
+    // reverting.
+    throw new StorageException(
+        String.format(
+            "Couldn't find a non-merge commit after encountering commit %s when trying to revert"
+                + " the submission of change %d",
+            potentialCommitToReturn.getName(), changeNotes.getChange().getChangeId()));
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    boolean projectStatePermitsWrite = false;
+    try {
+      projectStatePermitsWrite = projectCache.checkedGet(rsrc.getProject()).statePermitsWrite();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to check if project state permits write: %s", rsrc.getProject());
+    }
+    return new UiAction.Description()
+        .setLabel("Revert submission")
+        .setTitle(
+            "Revert this change and all changes that have been submitted together with this change")
+        .setVisible(
+            and(
+                change.isMerged()
+                    && change.getSubmissionId() != null
+                    && isChangePartOfSubmission(change.getSubmissionId())
+                    && projectStatePermitsWrite,
+                permissionBackend
+                    .user(rsrc.getUser())
+                    .ref(change.getDest())
+                    .testCond(CREATE_CHANGE)));
+  }
+
+  /**
+   * @param submissionId the submission id of the change.
+   * @return True if the submission has more than one change, false otherwise.
+   */
+  private Boolean isChangePartOfSubmission(String submissionId) {
+    return (queryProvider.get().setLimit(2).bySubmissionId(submissionId).size() > 1);
+  }
+
+  private class CreateCherryPickOp implements BatchUpdateOp {
+    private final ObjectId revCommitId;
+    private final String topic;
+    private final ObjectId computedChangeId;
+    private final Change.Id cherryPickRevertChangeId;
+    private final String groupName;
+    private final Timestamp timestamp;
+
+    CreateCherryPickOp(
+        ObjectId revCommitId,
+        String topic,
+        ObjectId computedChangeId,
+        Change.Id cherryPickRevertChangeId,
+        String groupName,
+        Timestamp timestamp) {
+      this.revCommitId = revCommitId;
+      this.topic = topic;
+      this.computedChangeId = computedChangeId;
+      this.cherryPickRevertChangeId = cherryPickRevertChangeId;
+      this.groupName = groupName;
+      this.timestamp = timestamp;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+      Result cherryPickResult =
+          cherryPickChange.cherryPick(
+              change,
+              change.getProject(),
+              revCommitId,
+              cherryPickInput,
+              BranchNameKey.create(
+                  change.getProject(), RefNames.fullName(cherryPickInput.destination)),
+              true,
+              timestamp,
+              topic,
+              change.getId(),
+              computedChangeId,
+              cherryPickRevertChangeId,
+              groupName);
+      // save the commit as base for next cherryPick of that branch
+      cherryPickInput.base =
+          changeNotesFactory
+              .createChecked(cherryPickResult.changeId())
+              .getCurrentPatchSet()
+              .commitId()
+              .getName();
+      results.add(
+          json.noOptions()
+              .format(change.getProject(), cherryPickResult.changeId(), ChangeInfo::new));
+      return true;
+    }
+  }
+
+  private class NotifyOp implements BatchUpdateOp {
+    private final Change change;
+    private final Change.Id revertChangeId;
+
+    NotifyOp(Change change, Change.Id revertChangeId) {
+      this.change = change;
+      this.revertChangeId = revertChangeId;
+    }
+
+    @Override
+    public void postUpdate(Context ctx) throws Exception {
+      changeReverted.fire(
+          change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
+      try {
+        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        cm.setFrom(ctx.getAccountId());
+        cm.setNotify(ctx.getNotify(change.getId()));
+        cm.send();
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot send email for revert change %s", change.getId());
+      }
+    }
+  }
+
+  /**
+   * create a message that describes the revert if the cherry-pick is successful, and point the
+   * revert of the change towards the cherry-pick. The cherry-pick is the updated change that acts
+   * as "revert-of" the original change.
+   */
+  private class PostRevertedMessageOp implements BatchUpdateOp {
+    private final ObjectId computedChangeId;
+
+    PostRevertedMessageOp(ObjectId computedChangeId) {
+      this.computedChangeId = computedChangeId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      Change change = ctx.getChange();
+      PatchSet.Id patchSetId = change.currentPatchSetId();
+      ChangeMessage changeMessage =
+          ChangeMessagesUtil.newMessage(
+              ctx,
+              "Created a revert of this change as I" + computedChangeId.getName(),
+              ChangeMessagesUtil.TAG_REVERT);
+      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewed.java b/java/com/google/gerrit/server/restapi/change/Reviewed.java
index 7152799..2793059 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewed.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewed.java
@@ -43,7 +43,7 @@
                       resource.getPatchKey().patchSetId(),
                       resource.getAccountId(),
                       resource.getPatchKey().fileName()));
-      return reviewFlagUpdated ? Response.created("") : Response.ok("");
+      return reviewFlagUpdated ? Response.created() : Response.ok();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index f07d815..39df82d 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -22,7 +22,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.ApprovalsUtil;
@@ -202,23 +201,25 @@
   private Map<Account.Id, MutableDouble> baseRanking(
       double baseWeight, String query, List<Account.Id> candidateList)
       throws IOException, ConfigInvalidException {
-    // Get the user's last 25 changes, check approvals
+    int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50);
+    // Get the user's last 25 changes, check reviewers
     try {
       List<ChangeData> result =
           queryProvider
               .get()
-              .setLimit(25)
-              .setRequestedFields(ChangeField.APPROVAL)
+              .setLimit(numberOfRelevantChanges)
+              .setRequestedFields(ChangeField.REVIEWER)
               .query(changeQueryBuilder.owner("self"));
       Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
       // Put those candidates at the bottom of the list
       candidateList.stream().forEach(id -> suggestions.put(id, new MutableDouble(0)));
 
       for (ChangeData cd : result) {
-        for (PatchSetApproval approval : cd.currentApprovals()) {
-          Account.Id id = approval.accountId();
-          if (Strings.isNullOrEmpty(query) || accountMatchesQuery(id, query)) {
-            suggestions.computeIfAbsent(id, (ignored) -> new MutableDouble(0)).add(baseWeight);
+        for (Account.Id reviewer : cd.reviewers().all()) {
+          if (Strings.isNullOrEmpty(query) || accountMatchesQuery(reviewer, query)) {
+            suggestions
+                .computeIfAbsent(reviewer, (ignored) -> new MutableDouble(0))
+                .add(baseWeight);
           }
         }
       }
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index b2714da..d702142 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -21,12 +21,11 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.restapi.account.AccountsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -37,22 +36,22 @@
 public class Reviewers implements ChildCollection<ChangeResource, ReviewerResource> {
   private final DynamicMap<RestView<ReviewerResource>> views;
   private final ApprovalsUtil approvalsUtil;
-  private final AccountsCollection accounts;
   private final ReviewerResource.Factory resourceFactory;
   private final ListReviewers list;
+  private final AccountResolver accountResolver;
 
   @Inject
   Reviewers(
       ApprovalsUtil approvalsUtil,
-      AccountsCollection accounts,
       ReviewerResource.Factory resourceFactory,
       DynamicMap<RestView<ReviewerResource>> views,
-      ListReviewers list) {
+      ListReviewers list,
+      AccountResolver accountResolver) {
     this.approvalsUtil = approvalsUtil;
-    this.accounts = accounts;
     this.resourceFactory = resourceFactory;
     this.views = views;
     this.list = list;
+    this.accountResolver = accountResolver;
   }
 
   @Override
@@ -68,22 +67,18 @@
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
       throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
-    Address address = Address.tryParse(id.get());
-
-    Account.Id accountId = null;
     try {
-      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-    } catch (ResourceNotFoundException e) {
-      if (address == null) {
-        throw e;
+
+      AccountResolver.Result result = accountResolver.resolveIgnoreVisibility(id.get());
+      if (fetchAccountIds(rsrc).contains(result.asUniqueUser().getAccountId())) {
+        return resourceFactory.create(rsrc, result.asUniqueUser().getAccountId());
+      }
+    } catch (AccountResolver.UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        throw new AuthException(e.getMessage(), e);
       }
     }
-    // See if the id exists as a reviewer for this change
-    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
-      return resourceFactory.create(rsrc, accountId);
-    }
-
-    // See if the address exists as a reviewer on the change
+    Address address = Address.tryParse(id.get());
     if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
       return new ReviewerResource(rsrc, address);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 288806c..8362e95 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -31,27 +32,25 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, String>
-    implements UiAction<ChangeResource> {
+public class SetReadyForReview
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
   private final WorkInProgressOp.Factory opFactory;
 
   @Inject
-  SetReadyForReview(RetryHelper retryHelper, WorkInProgressOp.Factory opFactory) {
-    super(retryHelper);
+  SetReadyForReview(BatchUpdate.Factory updateFactory, WorkInProgressOp.Factory opFactory) {
+    this.updateFactory = updateFactory;
     this.opFactory = opFactory;
   }
 
   @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+  public Response<String> apply(ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
 
@@ -69,7 +68,7 @@
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
       bu.execute();
-      return Response.ok("");
+      return Response.ok();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 3fb0295..fdaad9d 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -31,27 +32,25 @@
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, String>
-    implements UiAction<ChangeResource> {
+public class SetWorkInProgress
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
   private final WorkInProgressOp.Factory opFactory;
 
   @Inject
-  SetWorkInProgress(WorkInProgressOp.Factory opFactory, RetryHelper retryHelper) {
-    super(retryHelper);
+  SetWorkInProgress(BatchUpdate.Factory updateFactory, WorkInProgressOp.Factory opFactory) {
+    this.updateFactory = updateFactory;
     this.opFactory = opFactory;
   }
 
   @Override
-  protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+  public Response<String> apply(ChangeResource rsrc, Input input)
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
 
@@ -69,7 +68,7 @@
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
       bu.execute();
-      return Response.ok("");
+      return Response.ok();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index c4cca51..6fc3ece 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -19,7 +19,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
@@ -190,7 +189,8 @@
   @UsedAt(UsedAt.Project.GOOGLE)
   public Response<Output> mergeChange(
       RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
-      throws RestApiException, IOException {
+      throws RestApiException, IOException, UpdateException, ConfigInvalidException,
+          PermissionBackendException {
     Change change = rsrc.getChange();
     if (!change.isNew()) {
       throw new ResourceConflictException("change is " + ChangeUtil.status(change));
@@ -207,23 +207,15 @@
     try (MergeOp op = mergeOpProvider.get()) {
       Change updatedChange;
 
-      try {
-        updatedChange = op.merge(change, submitter, true, input, false);
-      } catch (Exception e) {
-        Throwables.throwIfInstanceOf(e, RestApiException.class);
-        return Response.<Output>internalServerError(e).traceId(op.getTraceId().orElse(null));
-      }
-
+      updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
         return Response.ok(new Output(change));
       }
 
-      String msg =
+      throw new IllegalStateException(
           String.format(
               "change %s of project %s unexpectedly had status %s after submit attempt",
-              updatedChange.getId(), updatedChange.getProject(), updatedChange.getStatus());
-      logger.atWarning().log(msg);
-      throw new RestApiException(msg);
+              updatedChange.getId(), updatedChange.getProject(), updatedChange.getStatus()));
     }
   }
 
@@ -471,12 +463,6 @@
       }
 
       Response<Output> response = submit.apply(new RevisionResource(rsrc, ps), input);
-      if (response instanceof Response.InternalServerError) {
-        Response.InternalServerError<?> ise = (Response.InternalServerError<?>) response;
-        return Response.<ChangeInfo>internalServerError(ise.cause())
-            .traceId(ise.traceId().orElse(null));
-      }
-
       return Response.ok(json.noOptions().format(response.value().change));
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
index 26d3233..999e736 100644
--- a/java/com/google/gerrit/server/restapi/change/Unignore.java
+++ b/java/com/google/gerrit/server/restapi/change/Unignore.java
@@ -50,7 +50,7 @@
     if (isIgnored(rsrc)) {
       stars.unignore(rsrc);
     }
-    return Response.ok("");
+    return Response.ok();
   }
 
   private boolean isIgnored(ChangeResource rsrc) {
diff --git a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
index b56f1b8..b55562e 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
@@ -32,6 +32,18 @@
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+/**
+ * REST endpoint to confirm an email address for an account.
+ *
+ * <p>This REST endpoint handles {@code PUT /config/server/email.confirm} requests.
+ *
+ * <p>When a user registers a new email address for their account (see {@link
+ * com.google.gerrit.server.restapi.account.CreateEmail}) an email with a confirmation link is sent
+ * to that address. When the receiver confirms the email by clicking on the confirmation link, this
+ * REST endpoint is invoked and the email address is added to the account. Confirming an email
+ * address for an account creates an external ID that links the email address to the account. An
+ * email address can only be added to an account if it is not assigned to any other account yet.
+ */
 @Singleton
 public class ConfirmEmail implements RestModifyView<ConfigResource, Input> {
   public static class Input {
diff --git a/java/com/google/gerrit/server/restapi/config/FlushCache.java b/java/com/google/gerrit/server/restapi/config/FlushCache.java
index 9ea9e33..f10ed8d 100644
--- a/java/com/google/gerrit/server/restapi/config/FlushCache.java
+++ b/java/com/google/gerrit/server/restapi/config/FlushCache.java
@@ -50,6 +50,6 @@
     }
 
     rsrc.getCache().invalidateAll();
-    return Response.ok("");
+    return Response.ok();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 2d504c7..bbe38a3 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -227,10 +228,10 @@
     info.updateDelay =
         (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
-    info.excludeMergeableInChangeInfo =
-        toBoolean(this.config.getBoolean("change", "api", "excludeMergeableInChangeInfo", false));
     info.disablePrivateChanges =
         toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
+    info.mergeabilityComputationBehavior =
+        MergeabilityComputationBehavior.fromConfig(config).name();
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
index c633af0..c9480c5 100644
--- a/java/com/google/gerrit/server/restapi/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -84,13 +84,13 @@
               "specifying caches is not allowed for operation 'FLUSH_ALL'");
         }
         flushAll();
-        return Response.ok("");
+        return Response.ok();
       case FLUSH:
         if (input.caches == null || input.caches.isEmpty()) {
           throw new BadRequestException("caches must be specified for operation 'FLUSH'");
         }
         flush(input.caches);
-        return Response.ok("");
+        return Response.ok();
       default:
         throw new BadRequestException("unsupported operation: " + input.operation);
     }
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index caff206..93d095d 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -222,7 +223,8 @@
 
     @Override
     public Response<AccountInfo> apply(GroupResource resource, IdString id, Input input)
-        throws Exception {
+        throws RestApiException, NotInternalGroupException, IOException, ConfigInvalidException,
+            PermissionBackendException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id.get();
       try {
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 585c014..531d350 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -41,7 +41,7 @@
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.CreateGroupArgs;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
@@ -196,7 +196,7 @@
 
     AccountGroup.Id groupId = AccountGroup.id(sequences.nextGroupId());
     AccountGroup.UUID uuid =
-        GroupUUID.make(
+        GroupUuid.make(
             createGroupArgs.getGroupName(),
             self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
     InternalGroupCreation groupCreation =
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index b9fd000..a7b2e2d 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
@@ -68,6 +69,9 @@
 
     Set<Account.Id> membersToRemove = new HashSet<>();
     for (String nameOrEmail : input.members) {
+      if (Strings.isNullOrEmpty(nameOrEmail)) {
+        continue;
+      }
       membersToRemove.add(accountResolver.resolve(nameOrEmail).asUnique().account().id());
     }
     AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 52fe9d0..65a7f4f 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -42,7 +42,7 @@
   private final GroupResolver groupResolver;
   private final Provider<CurrentUser> self;
 
-  private boolean hasQuery2;
+  private boolean hasQuery;
 
   @Inject
   public GroupsCollection(
@@ -62,12 +62,7 @@
 
   @Override
   public void setParams(ListMultimap<String, String> params) throws BadRequestException {
-    if (params.containsKey("query") && params.containsKey("query2")) {
-      throw new BadRequestException("\"query\" and \"query2\" options are mutually exclusive");
-    }
-
-    // The --query2 option is defined in QueryGroups
-    this.hasQuery2 = params.containsKey("query2");
+    this.hasQuery = params.containsKey("query");
   }
 
   @Override
@@ -79,7 +74,7 @@
       throw new ResourceNotFoundException();
     }
 
-    if (hasQuery2) {
+    if (hasQuery) {
       return queryGroups.get();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 899ed00..adc251c 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -129,21 +129,6 @@
     this.owned = owned;
   }
 
-  /**
-   * Add a group to inspect.
-   *
-   * @param uuid UUID of the group
-   * @deprecated use {@link #addGroup(AccountGroup.UUID)}.
-   */
-  @Deprecated
-  @Option(
-      name = "--query",
-      aliases = {"-q"},
-      usage = "group to inspect (deprecated: use --group/-g instead)")
-  void addGroup_Deprecated(AccountGroup.UUID uuid) {
-    addGroup(uuid);
-  }
-
   @Option(
       name = "--group",
       aliases = {"-g"},
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index a233111..380d42e 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -48,12 +48,9 @@
   private int start;
   private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
 
-  // TODO(ekempin): --query in ListGroups is marked as deprecated, once it is
-  // removed we want to rename --query2 to --query here.
-  /** --query (-q) is already used by {@link ListGroups} */
   @Option(
-      name = "--query2",
-      aliases = {"-q2"},
+      name = "--query",
+      aliases = {"-q"},
       usage = "group query")
   public void setQuery(String query) {
     this.query = query;
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
index 64e38b0..eb5473d 100644
--- a/java/com/google/gerrit/server/restapi/project/BanCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -18,14 +18,11 @@
 import com.google.gerrit.extensions.api.projects.BanCommitInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.git.BanCommitResult;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.restapi.project.BanCommit.BanResultInfo;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -34,20 +31,18 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
+/** The REST endpoint that marks commits as banned in a project. */
 @Singleton
-public class BanCommit
-    extends RetryingRestModifyView<ProjectResource, BanCommitInput, BanResultInfo> {
+public class BanCommit implements RestModifyView<ProjectResource, BanCommitInput> {
   private final com.google.gerrit.server.git.BanCommit banCommit;
 
   @Inject
-  BanCommit(RetryHelper retryHelper, com.google.gerrit.server.git.BanCommit banCommit) {
-    super(retryHelper);
+  BanCommit(com.google.gerrit.server.git.BanCommit banCommit) {
     this.banCommit = banCommit;
   }
 
   @Override
-  protected Response<BanResultInfo> applyImpl(
-      BatchUpdate.Factory updateFactory, ProjectResource rsrc, BanCommitInput input)
+  public Response<BanResultInfo> apply(ProjectResource rsrc, BanCommitInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException {
     BanResultInfo r = new BanResultInfo();
     if (input != null && input.commits != null && !input.commits.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
index 69a6da8..4864fde 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -120,7 +121,7 @@
           result.conflicts = ((ResolveMerger) m).getUnmergedPaths();
         }
       }
-    } catch (IllegalArgumentException e) {
+    } catch (InvalidMergeStrategyException e) {
       throw new BadRequestException(e.getMessage());
     }
     return Response.ok(result);
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index d5380c6..21d7f0b 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -16,16 +16,16 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
-import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.project.CommitResource;
@@ -33,15 +33,17 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.CommitPredicate;
+import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.Action;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,7 +58,6 @@
   private final GitRepositoryManager repoManager;
   private final RetryHelper retryHelper;
   private final ChangeIndexCollection indexes;
-  private final Provider<InternalChangeQuery> queryProvider;
   private final Reachable reachable;
 
   @Inject
@@ -65,13 +66,11 @@
       GitRepositoryManager repoManager,
       RetryHelper retryHelper,
       ChangeIndexCollection indexes,
-      Provider<InternalChangeQuery> queryProvider,
       Reachable reachable) {
     this.views = views;
     this.repoManager = repoManager;
     this.retryHelper = retryHelper;
     this.indexes = indexes;
-    this.queryProvider = queryProvider;
     this.reachable = reachable;
   }
 
@@ -112,28 +111,63 @@
     return views;
   }
 
+  /**
+   * @return true if {@code commit} is visible to the caller and {@code commit} is reachable from
+   *     the given branch.
+   */
+  public boolean canRead(ProjectState state, Repository repo, RevCommit commit, Ref ref) {
+    return reachable.fromRefs(state.getNameKey(), repo, commit, ImmutableList.of(ref));
+  }
+
   /** @return true if {@code commit} is visible to the caller. */
   public boolean canRead(ProjectState state, Repository repo, RevCommit commit) throws IOException {
     Project.NameKey project = state.getNameKey();
     if (indexes.getSearchIndex() == null) {
-      // No index in slaves, fall back to scanning refs.
+      // No index in slaves, fall back to scanning refs. We must inspect change refs too
+      // as the commit might be a patchset of a not yet submitted change.
       return reachable.fromRefs(project, repo, commit, repo.getRefDatabase().getRefs());
     }
 
-    // Check first if any change references the commit in question. This is much cheaper than ref
-    // visibility filtering and reachability computation.
+    // Check first if any patchset of any change references the commit in question. This is much
+    // cheaper than ref visibility filtering and reachability computation.
     List<ChangeData> changes =
-        executeIndexQuery(
-            () ->
-                queryProvider
-                    .get()
-                    .enforceVisibility(true)
-                    .setLimit(1)
-                    .byProjectCommit(project, commit));
+        retryHelper
+            .changeIndexQuery(
+                "queryChangesByProjectCommitWithLimit1",
+                q -> q.enforceVisibility(true).setLimit(1).byProjectCommit(project, commit))
+            .call();
     if (!changes.isEmpty()) {
       return true;
     }
 
+    // Maybe the commit was a merge commit of a change. Try to find promising candidates for
+    // branches to check, by seeing if its parents were associated to changes.
+    Predicate<ChangeData> pred =
+        Predicate.and(
+            new ProjectPredicate(project.get()),
+            Predicate.or(
+                Arrays.stream(commit.getParents())
+                    .map(parent -> new CommitPredicate(parent.getId().getName()))
+                    .collect(toImmutableList())));
+    changes =
+        retryHelper
+            .changeIndexQuery(
+                "queryChangesByProjectCommit", q -> q.enforceVisibility(true).query(pred))
+            .call();
+
+    Set<Ref> branchesForCommitParents = new HashSet<>(changes.size());
+    for (ChangeData cd : changes) {
+      Ref ref = repo.exactRef(cd.change().getDest().branch());
+      if (ref != null) {
+        branchesForCommitParents.add(ref);
+      }
+    }
+
+    if (reachable.fromRefs(
+        project, repo, commit, branchesForCommitParents.stream().collect(Collectors.toList()))) {
+      return true;
+    }
+
     // If we have already checked change refs using the change index, spare any further checks for
     // changes.
     List<Ref> refs =
@@ -142,14 +176,4 @@
             .collect(toImmutableList());
     return reachable.fromRefs(project, repo, commit, refs);
   }
-
-  private <T> T executeIndexQuery(Action<T> action) {
-    try {
-      return retryHelper.execute(
-          ActionType.INDEX_QUERY, action, StorageException.class::isInstance);
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new StorageException(e);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index fd6e024..c15fdeb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -163,8 +164,7 @@
               }
               refPrefix = RefUtil.getRefPrefix(refPrefix);
             }
-            // fall through
-            // $FALL-THROUGH$
+            throw new LockFailureException(String.format("Failed to create %s", ref), u);
           case FORCED:
           case IO_FAILURE:
           case NOT_ATTEMPTED:
@@ -174,9 +174,7 @@
           case REJECTED_MISSING_OBJECT:
           case REJECTED_OTHER_REASON:
           default:
-            {
-              throw new IOException(result.name());
-            }
+            throw new IOException(String.format("Failed to create %s: %s", ref, result.name()));
         }
 
         BranchInfo info = new BranchInfo();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
index 314df73..e9a0d7f 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -17,14 +17,17 @@
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
 @Singleton
@@ -42,10 +45,10 @@
 
   @Override
   public Response<DashboardInfo> apply(ProjectResource parent, IdString id, SetDashboardInput input)
-      throws Exception {
+      throws RestApiException, IOException, PermissionBackendException {
     parent.getProjectState().checkStatePermitsWrite();
     if (!DashboardsCollection.isDefaultDashboard(id)) {
-      throw new ResourceNotFoundException(id);
+      throw new MethodNotAllowedException("cannot create non-default dashboard");
     }
     SetDefaultDashboard set = setDefault.get();
     set.inherited = inherited;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
new file mode 100644
index 0000000..5d51527
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -0,0 +1,206 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CreateLabel
+    implements RestCollectionCreateView<ProjectResource, LabelResource, LabelDefinitionInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public CreateLabel(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<LabelDefinitionInfo> apply(
+      ProjectResource rsrc, IdString id, LabelDefinitionInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, IOException, ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new LabelDefinitionInput();
+    }
+
+    if (input.name != null && !input.name.equals(id.get())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      LabelType labelType = createLabel(config, id.get(), input);
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Update label");
+      }
+
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProjectState().getProject());
+
+      return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
+    }
+  }
+
+  /**
+   * Creates a new label.
+   *
+   * @param config the project config
+   * @param label the name of the new label
+   * @param input the input that describes the new label
+   * @return the created label type
+   * @throws BadRequestException if there was invalid data in the input
+   * @throws ResourceConflictException if the label cannot be created due to a conflict
+   */
+  public LabelType createLabel(ProjectConfig config, String label, LabelDefinitionInput input)
+      throws BadRequestException, ResourceConflictException {
+    if (config.getLabelSections().containsKey(label)) {
+      throw new ResourceConflictException(String.format("label %s already exists", label));
+    }
+
+    for (String labelName : config.getLabelSections().keySet()) {
+      if (labelName.equalsIgnoreCase(label)) {
+        throw new ResourceConflictException(
+            String.format("label %s conflicts with existing label %s", label, labelName));
+      }
+    }
+
+    if (input.values == null || input.values.isEmpty()) {
+      throw new BadRequestException("values are required");
+    }
+
+    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
+
+    LabelType labelType;
+    try {
+      labelType = new LabelType(label, values);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("invalid name: " + label, e);
+    }
+
+    if (input.function != null && !input.function.trim().isEmpty()) {
+      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+    } else {
+      labelType.setFunction(LabelFunction.MAX_WITH_BLOCK);
+    }
+
+    if (input.defaultValue != null) {
+      labelType.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+    }
+
+    if (input.branches != null) {
+      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+    }
+
+    if (input.canOverride != null) {
+      labelType.setCanOverride(input.canOverride);
+    }
+
+    if (input.copyAnyScore != null) {
+      labelType.setCopyAnyScore(input.copyAnyScore);
+    }
+
+    if (input.copyMinScore != null) {
+      labelType.setCopyMinScore(input.copyMinScore);
+    }
+
+    if (input.copyMaxScore != null) {
+      labelType.setCopyMaxScore(input.copyMaxScore);
+    }
+
+    if (input.copyAllScoresIfNoChange != null) {
+      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+    }
+
+    if (input.copyAllScoresIfNoCodeChange != null) {
+      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+    }
+
+    if (input.copyAllScoresOnTrivialRebase != null) {
+      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+    }
+
+    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+          input.copyAllScoresOnMergeFirstParentUpdate);
+    }
+
+    if (input.allowPostSubmit != null) {
+      labelType.setAllowPostSubmit(input.allowPostSubmit);
+    }
+
+    if (input.ignoreSelfApproval != null) {
+      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+    }
+
+    config.getLabelSections().put(labelType.getName(), labelType);
+
+    return labelType;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
index 9d9e5f5..e507f05 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
@@ -43,6 +43,6 @@
     }
 
     // TODO: Implement delete of dashboards by API.
-    throw new MethodNotAllowedException();
+    throw new MethodNotAllowedException("cannot delete non-default dashboard");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
new file mode 100644
index 0000000..531640c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.InputWithCommitMessage;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteLabel implements RestModifyView<LabelResource, InputWithCommitMessage> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public DeleteLabel(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(LabelResource rsrc, InputWithCommitMessage input)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
+          ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new InputWithCommitMessage();
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (!deleteLabel(config, rsrc.getLabelType().getName())) {
+        throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
+      }
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Delete label");
+      }
+
+      config.commit(md);
+    }
+
+    projectCache.evict(rsrc.getProject().getProjectState().getProject());
+
+    return Response.none();
+  }
+
+  /**
+   * Delete the given label from the given project config.
+   *
+   * @param config the project config from which the label should be deleted
+   * @param labelName the name of the label that should be deleted
+   * @return {@code true} if the label was deleted, {@code false} if the label was not found
+   */
+  public boolean deleteLabel(ProjectConfig config, String labelName) {
+    if (!config.getLabelSections().containsKey(labelName)) {
+      return false;
+    }
+
+    config.getLabelSections().remove(labelName);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 1979d61..2395bdd 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -43,7 +44,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.eclipse.jgit.errors.LockFailedException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
@@ -58,9 +58,6 @@
 public class DeleteRef {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final int MAX_LOCK_FAILURE_CALLS = 10;
-  private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
-
   private final Provider<IdentifiedUser> identifiedUser;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
@@ -126,23 +123,7 @@
       u.setNewObjectId(ObjectId.zeroId());
       u.setForceUpdate(true);
       refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
-      int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-      for (; ; ) {
-        try {
-          result = u.delete();
-        } catch (LockFailedException e) {
-          result = RefUpdate.Result.LOCK_FAILURE;
-        }
-        if (result == RefUpdate.Result.LOCK_FAILURE && --remainingLockFailureCalls > 0) {
-          try {
-            Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
-          } catch (InterruptedException ie) {
-            // ignore
-          }
-        } else {
-          break;
-        }
-      }
+      result = u.delete();
 
       switch (result) {
         case NEW:
@@ -160,8 +141,9 @@
           logger.atFine().log("Cannot delete current branch %s: %s", ref, result.name());
           throw new ResourceConflictException("cannot delete current branch");
 
-        case IO_FAILURE:
         case LOCK_FAILURE:
+          throw new LockFailureException(String.format("Cannot delete %s", ref), u);
+        case IO_FAILURE:
         case NOT_ATTEMPTED:
         case REJECTED:
         case RENAMED:
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 0ee8279..0d5ab88 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -86,7 +87,7 @@
 
     @Override
     public Response<Map<String, FileInfo>> apply(CommitResource resource)
-        throws PatchListNotAvailableException {
+        throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       PatchListKey key;
 
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
index 25a2c90..c5423e6 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -43,6 +43,7 @@
 import java.util.Collections;
 import java.util.Optional;
 
+/** REST endpoint that executes GC on a project. */
 @RequiresCapability(GlobalCapability.RUN_GC)
 @Singleton
 public class GarbageCollect
diff --git a/java/com/google/gerrit/server/restapi/project/GetLabel.java b/java/com/google/gerrit/server/restapi/project/GetLabel.java
new file mode 100644
index 0000000..626cb42
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetLabel.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetLabel implements RestReadView<LabelResource> {
+  @Override
+  public Response<LabelDefinitionInfo> apply(LabelResource rsrc)
+      throws AuthException, BadRequestException {
+    return Response.ok(
+        LabelDefinitionJson.format(rsrc.getProject().getNameKey(), rsrc.getLabelType()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
new file mode 100644
index 0000000..1e288f4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.project.RefPattern;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.Set;
+
+public class LabelDefinitionInputParser {
+  public static LabelFunction parseFunction(String functionString) throws BadRequestException {
+    Optional<LabelFunction> function = LabelFunction.parse(functionString.trim());
+    return function.orElseThrow(
+        () -> new BadRequestException("unknown function: " + functionString));
+  }
+
+  public static List<LabelValue> parseValues(Map<String, String> values)
+      throws BadRequestException {
+    List<LabelValue> valueList = new ArrayList<>();
+    Set<Short> allValues = new HashSet<>();
+    for (Entry<String, String> e : values.entrySet()) {
+      short value;
+      try {
+        value = Shorts.checkedCast(PermissionRule.parseInt(e.getKey().trim()));
+      } catch (NumberFormatException ex) {
+        throw new BadRequestException("invalid value: " + e.getKey(), ex);
+      }
+      if (!allValues.add(value)) {
+        throw new BadRequestException("duplicate value: " + value);
+      }
+      String valueDescription = e.getValue().trim();
+      if (valueDescription.isEmpty()) {
+        throw new BadRequestException("description for value '" + e.getKey() + "' cannot be empty");
+      }
+      valueList.add(new LabelValue(value, valueDescription));
+    }
+    return valueList;
+  }
+
+  public static short parseDefaultValue(LabelType labelType, short defaultValue)
+      throws BadRequestException {
+    if (labelType.getValue(defaultValue) == null) {
+      throw new BadRequestException("invalid default value: " + defaultValue);
+    }
+    return defaultValue;
+  }
+
+  public static List<String> parseBranches(List<String> branches) throws BadRequestException {
+    List<String> validBranches = new ArrayList<>();
+    for (String branch : branches) {
+      String newBranch = branch.trim();
+      if (newBranch.isEmpty()) {
+        continue;
+      }
+      if (!RefPattern.isRE(newBranch) && !newBranch.startsWith(RefNames.REFS)) {
+        newBranch = RefNames.REFS_HEADS + newBranch;
+      }
+      try {
+        RefPattern.validate(newBranch);
+      } catch (InvalidNameException e) {
+        throw new BadRequestException("invalid branch: " + branch, e);
+      }
+      validBranches.add(newBranch);
+    }
+    return validBranches;
+  }
+
+  private LabelDefinitionInputParser() {}
+}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
new file mode 100644
index 0000000..0409729
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class LabelsCollection implements ChildCollection<ProjectResource, LabelResource> {
+  private final Provider<ListLabels> list;
+  private final DynamicMap<RestView<LabelResource>> views;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  LabelsCollection(
+      Provider<ListLabels> list,
+      DynamicMap<RestView<LabelResource>> views,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
+    this.list = list;
+    this.views = views;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public LabelResource parse(ProjectResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(parent.getNameKey())
+        .check(ProjectPermission.READ_CONFIG);
+    LabelType labelType = parent.getProjectState().getConfig().getLabelSections().get(id.get());
+    if (labelType == null) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new LabelResource(parent, labelType);
+  }
+
+  @Override
+  public DynamicMap<RestView<LabelResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
new file mode 100644
index 0000000..19a8915
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+public class ListLabels implements RestReadView<ProjectResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public ListLabels(Provider<CurrentUser> user, PermissionBackend permissionBackend) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Option(name = "--inherited", usage = "to include inherited label definitions")
+  private boolean inherited;
+
+  public ListLabels withInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
+  @Override
+  public Response<List<LabelDefinitionInfo>> apply(ProjectResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (inherited) {
+      List<LabelDefinitionInfo> allLabels = new ArrayList<>();
+      for (ProjectState projectState : rsrc.getProjectState().treeInOrder()) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(projectState.getNameKey())
+              .check(ProjectPermission.READ_CONFIG);
+        } catch (AuthException e) {
+          throw new AuthException(projectState.getNameKey() + ": " + e.getMessage(), e);
+        }
+        allLabels.addAll(listLabels(projectState));
+      }
+      return Response.ok(allLabels);
+    }
+
+    permissionBackend.currentUser().project(rsrc.getNameKey()).check(ProjectPermission.READ_CONFIG);
+    return Response.ok(listLabels(rsrc.getProjectState()));
+  }
+
+  private List<LabelDefinitionInfo> listLabels(ProjectState projectState) {
+    Collection<LabelType> labelTypes = projectState.getConfig().getLabelSections().values();
+    List<LabelDefinitionInfo> labels = new ArrayList<>(labelTypes.size());
+    for (LabelType labelType : labelTypes) {
+      labels.add(LabelDefinitionJson.format(projectState.getNameKey(), labelType));
+    }
+    labels.sort(Comparator.comparing(l -> l.name));
+    return labels;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index ff6d30e5..123c78a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -41,8 +41,8 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -127,10 +127,10 @@
         permissionBackend.currentUser().project(resource.getNameKey());
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
-      Map<String, Ref> all =
+      Collection<Ref> all =
           visibleTags(
               resource.getNameKey(), repo, repo.getRefDatabase().getRefsByPrefix(Constants.R_TAGS));
-      for (Ref ref : all.values()) {
+      for (Ref ref : all) {
         tags.add(
             createTagInfo(perm.ref(ref.getName()), ref, rw, resource.getProjectState(), links));
       }
@@ -223,7 +223,7 @@
     }
   }
 
-  private Map<String, Ref> visibleTags(Project.NameKey project, Repository repo, List<Ref> tags)
+  private Collection<Ref> visibleTags(Project.NameKey project, Repository repo, List<Ref> tags)
       throws PermissionBackendException {
     return permissionBackend
         .currentUser()
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index de5661d..5b3ea30 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.project.CommitResource.COMMIT_KIND;
 import static com.google.gerrit.server.project.DashboardResource.DASHBOARD_KIND;
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
+import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
@@ -42,6 +43,7 @@
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
+    DynamicMap.mapOf(binder(), LABEL_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
 
@@ -65,6 +67,13 @@
     child(PROJECT_KIND, "children").to(ChildProjectsCollection.class);
     get(CHILD_PROJECT_KIND).to(GetChildProject.class);
 
+    child(PROJECT_KIND, "labels").to(LabelsCollection.class);
+    create(LABEL_KIND).to(CreateLabel.class);
+    get(LABEL_KIND).to(GetLabel.class);
+    put(LABEL_KIND).to(SetLabel.class);
+    delete(LABEL_KIND).to(DeleteLabel.class);
+    postOnCollection(LABEL_KIND).to(PostLabels.class);
+
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
 
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
new file mode 100644
index 0000000..8835359
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** REST endpoint that allows to add, update and delete label definitions in a batch. */
+@Singleton
+public class PostLabels
+    implements RestCollectionModifyView<ProjectResource, LabelResource, BatchLabelInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final DeleteLabel deleteLabel;
+  private final CreateLabel createLabel;
+  private final SetLabel setLabel;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public PostLabels(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      DeleteLabel deleteLabel,
+      CreateLabel createLabel,
+      SetLabel setLabel,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.deleteLabel = deleteLabel;
+    this.createLabel = createLabel;
+    this.setLabel = setLabel;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource rsrc, BatchLabelInput input)
+      throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+          ConfigInvalidException, BadRequestException, ResourceConflictException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new BatchLabelInput();
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      boolean dirty = false;
+
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (input.delete != null && !input.delete.isEmpty()) {
+        for (String labelName : input.delete) {
+          if (!deleteLabel.deleteLabel(config, labelName.trim())) {
+            throw new UnprocessableEntityException(String.format("label %s not found", labelName));
+          }
+        }
+        dirty = true;
+      }
+
+      if (input.create != null && !input.create.isEmpty()) {
+        for (LabelDefinitionInput labelInput : input.create) {
+          if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
+            throw new BadRequestException("label name is required for new label");
+          }
+          if (labelInput.commitMessage != null) {
+            throw new BadRequestException("commit message on label definition input not supported");
+          }
+          createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+        }
+        dirty = true;
+      }
+
+      if (input.update != null && !input.update.isEmpty()) {
+        for (Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
+          LabelType labelType = config.getLabelSections().get(e.getKey().trim());
+          if (labelType == null) {
+            throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
+          }
+          if (e.getValue().commitMessage != null) {
+            throw new BadRequestException("commit message on label definition input not supported");
+          }
+          setLabel.updateLabel(config, labelType, e.getValue());
+        }
+        dirty = true;
+      }
+
+      if (input.commitMessage != null) {
+        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+      } else {
+        md.setMessage("Update labels");
+      }
+
+      if (dirty) {
+        config.commit(md);
+        projectCache.evict(rsrc.getProjectState().getProject());
+      }
+    }
+
+    return Response.ok("");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 4aecb4a..9f9433b 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import static com.google.gerrit.server.project.ProjectConfig.COMMENTLINK;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_ENABLED;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_LINK;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_MATCH;
+
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.CommentLinkInput;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
@@ -59,6 +65,7 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
@@ -154,6 +161,10 @@
         setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
       }
 
+      if (input.commentLinks != null) {
+        updateCommentLinks(projectConfig, input.commentLinks);
+      }
+
       md.setMessage("Modified project settings\n");
       try {
         projectConfig.commit(md);
@@ -278,6 +289,25 @@
     }
   }
 
+  private void updateCommentLinks(
+      ProjectConfig projectConfig, Map<String, CommentLinkInput> input) {
+    for (Map.Entry<String, CommentLinkInput> e : input.entrySet()) {
+      String name = e.getKey();
+      CommentLinkInput value = e.getValue();
+      if (value != null) {
+        // Add or update the commentlink section
+        Config cfg = new Config();
+        cfg.setString(COMMENTLINK, name, KEY_MATCH, value.match);
+        cfg.setString(COMMENTLINK, name, KEY_LINK, value.link);
+        cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
+        projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
+      } else {
+        // Delete the commentlink section
+        projectConfig.removeCommentLinkSection(name);
+      }
+    }
+  }
+
   private static void validateProjectConfigEntryIsEditable(
       ProjectConfigEntry projectConfigEntry,
       ProjectState projectState,
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index 7066d9a..e4f7df5 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.query.project.ProjectQueryBuilder;
 import com.google.gerrit.server.query.project.ProjectQueryProcessor;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.List;
 import org.kohsuke.args4j.Option;
@@ -38,7 +39,7 @@
 public class QueryProjects implements RestReadView<TopLevelResource> {
   private final ProjectIndexCollection indexes;
   private final ProjectQueryBuilder queryBuilder;
-  private final ProjectQueryProcessor queryProcessor;
+  private final Provider<ProjectQueryProcessor> queryProcessorProvider;
   private final ProjectJson json;
 
   private String query;
@@ -78,11 +79,11 @@
   protected QueryProjects(
       ProjectIndexCollection indexes,
       ProjectQueryBuilder queryBuilder,
-      ProjectQueryProcessor queryProcessor,
+      Provider<ProjectQueryProcessor> queryProcessorProvider,
       ProjectJson json) {
     this.indexes = indexes;
     this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
+    this.queryProcessorProvider = queryProcessorProvider;
     this.json = json;
   }
 
@@ -102,6 +103,8 @@
       throw new MethodNotAllowedException("no project index");
     }
 
+    ProjectQueryProcessor queryProcessor = queryProcessorProvider.get();
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
index e8e0c0d..56c62dc 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
@@ -40,7 +40,7 @@
       return defaultSetter.get().apply(resource, input);
     }
 
-    // TODO: Implement creation/update of dashboards by API.
-    throw new MethodNotAllowedException();
+    // TODO: Implement update of dashboards by API.
+    throw new MethodNotAllowedException("cannot update non-default dashboard");
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 49b5cab..9920be0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -24,9 +24,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectCache;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Option;
@@ -67,7 +70,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource rsrc, SetDashboardInput input)
-      throws Exception {
+      throws RestApiException, IOException, PermissionBackendException {
     if (input == null) {
       input = new SetDashboardInput(); // Delete would set input to null.
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetHead.java b/java/com/google/gerrit/server/restapi/project/SetHead.java
index 0afea5c..ae70e46 100644
--- a/java/com/google/gerrit/server/restapi/project/SetHead.java
+++ b/java/com/google/gerrit/server/restapi/project/SetHead.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -96,9 +97,10 @@
           case FORCED:
           case NEW:
             break;
+          case LOCK_FAILURE:
+            throw new LockFailureException("Setting HEAD failed", u);
           case FAST_FORWARD:
           case IO_FAILURE:
-          case LOCK_FAILURE:
           case NOT_ATTEMPTED:
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
new file mode 100644
index 0000000..824b4ed
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -0,0 +1,222 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.LabelDefinitionJson;
+import com.google.gerrit.server.project.LabelResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SetLabel implements RestModifyView<LabelResource, LabelDefinitionInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public SetLabel(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<LabelDefinitionInfo> apply(LabelResource rsrc, LabelDefinitionInput input)
+      throws AuthException, BadRequestException, ResourceConflictException,
+          PermissionBackendException, IOException, ConfigInvalidException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new LabelDefinitionInput();
+    }
+
+    LabelType labelType = rsrc.getLabelType();
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (updateLabel(config, labelType, input)) {
+        if (input.commitMessage != null) {
+          md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
+        } else {
+          md.setMessage("Update label");
+        }
+
+        config.commit(md);
+        projectCache.evict(rsrc.getProject().getProjectState().getProject());
+      }
+    }
+    return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
+  }
+
+  /**
+   * Updates the given label.
+   *
+   * @param config the project config
+   * @param labelType the label type that should be updated
+   * @param input the input that describes the label update
+   * @return whether the label type was modified
+   * @throws BadRequestException if there was invalid data in the input
+   * @throws ResourceConflictException if the update cannot be applied due to a conflict
+   */
+  public boolean updateLabel(ProjectConfig config, LabelType labelType, LabelDefinitionInput input)
+      throws BadRequestException, ResourceConflictException {
+    boolean dirty = false;
+
+    config.getLabelSections().remove(labelType.getName());
+
+    if (input.name != null) {
+      String newName = input.name.trim();
+      if (newName.isEmpty()) {
+        throw new BadRequestException("name cannot be empty");
+      }
+      if (!newName.equals(labelType.getName())) {
+        if (config.getLabelSections().containsKey(newName)) {
+          throw new ResourceConflictException(String.format("name %s already in use", newName));
+        }
+
+        for (String labelName : config.getLabelSections().keySet()) {
+          if (labelName.equalsIgnoreCase(newName)) {
+            throw new ResourceConflictException(
+                String.format("name %s conflicts with existing label %s", newName, labelName));
+          }
+        }
+
+        try {
+          labelType.setName(newName);
+        } catch (IllegalArgumentException e) {
+          throw new BadRequestException("invalid name: " + input.name, e);
+        }
+        dirty = true;
+      }
+    }
+
+    if (input.function != null) {
+      if (input.function.trim().isEmpty()) {
+        throw new BadRequestException("function cannot be empty");
+      }
+      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+      dirty = true;
+    }
+
+    if (input.values != null) {
+      if (input.values.isEmpty()) {
+        throw new BadRequestException("values cannot be empty");
+      }
+      labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
+      dirty = true;
+    }
+
+    if (input.defaultValue != null) {
+      labelType.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+      dirty = true;
+    }
+
+    if (input.branches != null) {
+      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+      dirty = true;
+    }
+
+    if (input.canOverride != null) {
+      labelType.setCanOverride(input.canOverride);
+      dirty = true;
+    }
+
+    if (input.copyAnyScore != null) {
+      labelType.setCopyAnyScore(input.copyAnyScore);
+      dirty = true;
+    }
+
+    if (input.copyMinScore != null) {
+      labelType.setCopyMinScore(input.copyMinScore);
+      dirty = true;
+    }
+
+    if (input.copyMaxScore != null) {
+      labelType.setCopyMaxScore(input.copyMaxScore);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresIfNoChange != null) {
+      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+    }
+
+    if (input.copyAllScoresIfNoCodeChange != null) {
+      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresOnTrivialRebase != null) {
+      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+      dirty = true;
+    }
+
+    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+          input.copyAllScoresOnMergeFirstParentUpdate);
+      dirty = true;
+    }
+
+    if (input.allowPostSubmit != null) {
+      labelType.setAllowPostSubmit(input.allowPostSubmit);
+      dirty = true;
+    }
+
+    if (input.ignoreSelfApproval != null) {
+      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+      dirty = true;
+    }
+
+    config.getLabelSections().put(labelType.getName(), labelType);
+
+    return dirty;
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index ba78724..1a563ad 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -205,17 +205,8 @@
       this.anonymousUser = anonymousUser;
       this.patchsetUtil = patchsetUtil;
       this.emails = emails;
-
-      int limit = config.getInt("rules", null, "reductionLimit", 100000);
-      reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
-
-      limit =
-          config.getInt(
-              "rules",
-              null,
-              "compileReductionLimit",
-              (int) Math.min(10L * limit, Integer.MAX_VALUE));
-      compileLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
+      this.reductionLimit = RuleUtil.reductionLimit(config);
+      this.compileLimit = RuleUtil.compileReductionLimit(config);
 
       logger.atInfo().log("reductionLimit: %d, compileLimit: %d", reductionLimit, compileLimit);
     }
diff --git a/java/com/google/gerrit/server/rules/RuleUtil.java b/java/com/google/gerrit/server/rules/RuleUtil.java
new file mode 100644
index 0000000..f4e7eff
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/RuleUtil.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules;
+
+import org.eclipse.jgit.lib.Config;
+
+/** Provides utility methods for configuring and running Prolog rules inside Gerrit. */
+public class RuleUtil {
+
+  /**
+   * Returns the reduction limit to be applied to the Prolog machine to prevent infinite loops and
+   * other forms of computational overflow.
+   */
+  public static int reductionLimit(Config gerritConfig) {
+    int limit = gerritConfig.getInt("rules", null, "reductionLimit", 100000);
+    return limit <= 0 ? Integer.MAX_VALUE : limit;
+  }
+
+  /**
+   * Returns the compile reduction limit to be applied to the Prolog machine to prevent infinite
+   * loops and other forms of computational overflow. The compiled reduction limit should be used
+   * when user-provided Prolog code is compiled by the interpreter before the limit gets applied.
+   */
+  public static int compileReductionLimit(Config gerritConfig) {
+    int limit =
+        gerritConfig.getInt(
+            "rules",
+            null,
+            "compileReductionLimit",
+            (int) Math.min(10L * reductionLimit(gerritConfig), Integer.MAX_VALUE));
+    return limit <= 0 ? Integer.MAX_VALUE : limit;
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index 0cd21a4..ed67e68 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -87,6 +87,7 @@
 
   private final boolean enableProjectRules;
   private final int maxDbSize;
+  private final int compileReductionLimit;
   private final int maxSrcBytes;
   private final Path cacheDir;
   private final Path rulesDir;
@@ -104,6 +105,7 @@
       PluginSetContext<PredicateProvider> predicateProviders,
       @Named(CACHE_NAME) Cache<ObjectId, PrologMachineCopy> machineCache) {
     maxDbSize = config.getInt("rules", null, "maxPrologDatabaseSize", 256);
+    compileReductionLimit = RuleUtil.compileReductionLimit(config);
     maxSrcBytes = config.getInt("rules", null, "maxSourceBytes", 128 << 10);
     enableProjectRules = config.getBoolean("rules", null, "enable", true) && maxSrcBytes > 0;
     cacheDir = site.resolve(config.getString("cache", null, "directory"));
@@ -252,6 +254,9 @@
   private BufferingPrologControl newEmptyMachine(ClassLoader cl) {
     BufferingPrologControl ctl = new BufferingPrologControl();
     ctl.setMaxDatabaseSize(maxDbSize);
+    // Use the compiled reduction limit because the first term evaluation is done with
+    // consult_stream - an internal, combined Prolog term.
+    ctl.setReductionLimit(compileReductionLimit);
     ctl.setPrologClassLoader(
         new PrologClassLoader(new PredicateClassLoader(predicateProviders, cl)));
     ctl.setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 26f1990..78fa5bd 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -211,7 +211,7 @@
   }
 
   private GroupReference createGroupReference(String name) {
-    AccountGroup.UUID groupUuid = GroupUUID.make(name, serverUser);
+    AccountGroup.UUID groupUuid = GroupUuid.make(name, serverUser);
     return new GroupReference(groupUuid, name);
   }
 
diff --git a/java/com/google/gerrit/server/schema/Schema_180.java b/java/com/google/gerrit/server/schema/Schema_180.java
index 6912b3e..7e13d0d 100644
--- a/java/com/google/gerrit/server/schema/Schema_180.java
+++ b/java/com/google/gerrit/server/schema/Schema_180.java
@@ -14,6 +14,15 @@
 
 package com.google.gerrit.server.schema;
 
+/**
+ * Schema 180 for Gerrit metadata.
+ *
+ * <p>180 is the first schema version that is supported by NoteDb. All previous schema versions were
+ * for ReviewDb. Since ReviewDb no longer exists those schema versions have been deleted.
+ *
+ * <p>Upgrading to this schema version creates the {@code refs/meta/version} ref in NoteDb that
+ * stores the number of the current schema version.
+ */
 public class Schema_180 implements NoteDbSchemaVersion {
   @Override
   public void upgrade(Arguments args, UpdateUI ui) {
diff --git a/java/com/google/gerrit/server/schema/Schema_181.java b/java/com/google/gerrit/server/schema/Schema_181.java
index 5a238ef..d357ae7 100644
--- a/java/com/google/gerrit/server/schema/Schema_181.java
+++ b/java/com/google/gerrit/server/schema/Schema_181.java
@@ -17,6 +17,12 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import org.eclipse.jgit.lib.Repository;
 
+/**
+ * Schema 181 for Gerrit metadata.
+ *
+ * <p>Upgrading to this schema version populates the GPG subkey to master key map (see {@link
+ * PublicKeyStore}.
+ */
 public class Schema_181 implements NoteDbSchemaVersion {
   @Override
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index adb75a4..79ae43b 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
@@ -41,6 +40,7 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -76,7 +76,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryHelper.ActionType;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -91,7 +90,6 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -238,7 +236,7 @@
   private final Map<Change.Id, Change> updatedChanges;
 
   private Timestamp ts;
-  private RequestId submissionId;
+  private SubmissionId submissionId;
   private IdentifiedUser caller;
 
   private MergeOpRepoManager orm;
@@ -248,7 +246,6 @@
   private Set<Project.NameKey> allProjects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
-  private String traceId;
 
   @Inject
   MergeOp(
@@ -451,7 +448,7 @@
     this.dryrun = dryrun;
     this.caller = caller;
     this.ts = TimeUtil.nowTs();
-    this.submissionId = new RequestId(change.getId().toString());
+    this.submissionId = new SubmissionId(change);
 
     try (TraceContext traceContext =
         TraceContext.open().addTag(RequestId.Type.SUBMISSION_ID, submissionId)) {
@@ -461,11 +458,17 @@
       try {
         ChangeSet indexBackedChangeSet =
             mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
-        checkState(
-            indexBackedChangeSet.ids().contains(change.getId()),
-            "change %s missing from %s",
-            change.getId(),
-            indexBackedChangeSet);
+        if (!indexBackedChangeSet.ids().contains(change.getId())) {
+          // indexBackedChangeSet contains only open changes, if the change is missing in this set
+          // it might be that the change was concurrently submitted in the meantime.
+          change = changeDataFactory.create(change).reloadChange();
+          if (!change.isNew()) {
+            throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+          }
+          throw new IllegalStateException(
+              String.format("change %s missing from %s", change.getId(), indexBackedChangeSet));
+        }
+
         if (indexBackedChangeSet.furtherHiddenChanges()) {
           throw new AuthException(
               "A change to be submitted with " + change.getId() + " is not visible");
@@ -484,44 +487,39 @@
         }
 
         RetryTracker retryTracker = new RetryTracker();
-        retryHelper.execute(
-            updateFactory -> {
-              long attempt = retryTracker.lastAttemptNumber + 1;
-              boolean isRetry = attempt > 1;
-              if (isRetry) {
-                logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
-                this.ts = TimeUtil.nowTs();
-                openRepoManager();
-              }
-              this.commitStatus = new CommitStatus(cs, isRetry);
-              if (checkSubmitRules) {
-                logger.atFine().log("Checking submit rules and state");
-                checkSubmitRulesAndState(cs, isRetry);
-              } else {
-                logger.atFine().log("Bypassing submit rules");
-                bypassSubmitRules(cs, isRetry);
-              }
-              try {
-                integrateIntoHistory(cs);
-              } catch (IntegrationException e) {
-                logger.atSevere().withCause(e).log("Error from integrateIntoHistory");
-                throw new ResourceConflictException(e.getMessage(), e);
-              }
-              return null;
-            },
-            RetryHelper.options()
-                .listener(retryTracker)
-                // Up to the entire submit operation is retried, including possibly many projects.
-                // Multiply the timeout by the number of projects we're actually attempting to
-                // submit.
-                .timeout(
-                    retryHelper
-                        .getDefaultTimeout(ActionType.CHANGE_UPDATE)
-                        .multipliedBy(cs.projects().size()))
-                .caller(getClass())
-                .retryWithTrace(t -> !(t instanceof RestApiException))
-                .onAutoTrace(traceId -> this.traceId = traceId)
-                .build());
+        retryHelper
+            .changeUpdate(
+                "integrateIntoHistory",
+                updateFactory -> {
+                  long attempt = retryTracker.lastAttemptNumber + 1;
+                  boolean isRetry = attempt > 1;
+                  if (isRetry) {
+                    logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
+                    this.ts = TimeUtil.nowTs();
+                    openRepoManager();
+                  }
+                  this.commitStatus = new CommitStatus(cs, isRetry);
+                  if (checkSubmitRules) {
+                    logger.atFine().log("Checking submit rules and state");
+                    checkSubmitRulesAndState(cs, isRetry);
+                  } else {
+                    logger.atFine().log("Bypassing submit rules");
+                    bypassSubmitRules(cs, isRetry);
+                  }
+                  try {
+                    integrateIntoHistory(cs);
+                  } catch (IntegrationException e) {
+                    logger.atWarning().withCause(e).log("Error from integrateIntoHistory");
+                    throw new ResourceConflictException(e.getMessage(), e);
+                  }
+                  return null;
+                })
+            .listener(retryTracker)
+            // Up to the entire submit operation is retried, including possibly many projects.
+            // Multiply the timeout by the number of projects we're actually attempting to
+            // submit.
+            .defaultTimeoutMultiplier(cs.projects().size())
+            .call();
 
         if (projects > 1) {
           topicMetrics.topicSubmissionsCompleted.increment();
@@ -541,10 +539,6 @@
     }
   }
 
-  public Optional<String> getTraceId() {
-    return Optional.ofNullable(traceId);
-  }
-
   private void openRepoManager() {
     if (orm != null) {
       orm.close();
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index c2577e7..9a7ced5 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -42,10 +42,9 @@
 import java.util.Map;
 import java.util.Objects;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -130,19 +129,17 @@
   }
 
   public static class OpenBranch {
-    final RefUpdate update;
     final CodeReviewCommit oldTip;
     MergeTip mergeTip;
 
     OpenBranch(OpenRepo or, BranchNameKey name) throws IntegrationException {
       try {
-        update = or.repo.updateRef(name.branch());
-        if (update.getOldObjectId() != null) {
-          oldTip = or.rw.parseCommit(update.getOldObjectId());
+        Ref ref = or.getRepo().exactRef(name.branch());
+        if (ref != null) {
+          oldTip = or.rw.parseCommit(ref.getObjectId());
         } else if (Objects.equals(or.repo.getFullBranch(), name.branch())
             || Objects.equals(RefNames.REFS_CONFIG, name.branch())) {
           oldTip = null;
-          update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
           throw new IntegrationException(
               "The destination branch " + name + " does not exist anymore.");
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index bcebc7f..f8540fb 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,17 +18,19 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginContext;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -53,13 +55,16 @@
  * included.
  */
 public class MergeSuperSet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
   private final DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation;
   private final PermissionBackend permissionBackend;
   private final Config cfg;
   private final ProjectCache projectCache;
+  private final ChangeNotes.Factory notesFactory;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -67,17 +72,21 @@
   @Inject
   MergeSuperSet(
       @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
       Provider<InternalChangeQuery> queryProvider,
       Provider<MergeOpRepoManager> repoManagerProvider,
       DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory) {
     this.cfg = cfg;
+    this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
     this.repoManagerProvider = repoManagerProvider;
     this.mergeSuperSetComputation = mergeSuperSetComputation;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.notesFactory = notesFactory;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -98,14 +107,7 @@
         orm = repoManagerProvider.get();
         closeOrm = true;
       }
-      List<ChangeData> cds = queryProvider.get().byLegacyChangeId(change.getId());
-      checkState(
-          cds.size() == 1,
-          "Expected exactly one ChangeData for change ID %s, got %s",
-          change.getId(),
-          cds.size());
-      ChangeData cd = Iterables.getFirst(cds, null);
-
+      ChangeData cd = changeDataFactory.create(change.getProject(), change.getId());
       boolean visible = false;
       if (cd != null) {
         ProjectState projectState = projectCache.checkedGet(cd.project());
@@ -215,8 +217,23 @@
       return false;
     }
 
+    ChangeNotes notes;
     try {
-      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
+      notes = cd.notes();
+    } catch (NoSuchChangeException e) {
+      // The change was found in the index but is missing in NoteDb.
+      // This can happen in systems with multiple primary nodes when the replication of the index
+      // documents is faster than the replication of the Git data.
+      // Instead of failing, create the change notes from the index data so that the read permission
+      // check can be performed successfully.
+      logger.atWarning().log(
+          "Got change %d of project %s from index, but couldn't find it in NoteDb",
+          cd.getId().get(), cd.project().get());
+      notes = notesFactory.createFromIndexedChange(cd.change());
+    }
+
+    try {
+      permissionBackend.user(user).change(notes).check(ChangePermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4c68e1b..efb2f76 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -42,7 +43,6 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
-import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -94,7 +94,7 @@
           RevFlag canMergeFlag,
           Set<RevCommit> alreadyAccepted,
           Set<CodeReviewCommit> incoming,
-          RequestId submissionId,
+          SubmissionId submissionId,
           SubmitInput submitInput,
           SubmoduleOp submoduleOp,
           boolean dryrun);
@@ -125,7 +125,7 @@
     final MergeTip mergeTip;
     final RevFlag canMergeFlag;
     final Set<RevCommit> alreadyAccepted;
-    final RequestId submissionId;
+    final SubmissionId submissionId;
     final SubmitType submitType;
     final SubmitInput submitInput;
     final SubmoduleOp submoduleOp;
@@ -164,7 +164,7 @@
         @Assisted RevFlag canMergeFlag,
         @Assisted Set<RevCommit> alreadyAccepted,
         @Assisted Set<CodeReviewCommit> incoming,
-        @Assisted RequestId submissionId,
+        @Assisted SubmissionId submissionId,
         @Assisted SubmitType submitType,
         @Assisted SubmitInput submitInput,
         @Assisted SubmoduleOp submoduleOp,
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index cba572bc..a015665 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -16,13 +16,13 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.submit.MergeOp.CommitStatus;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -52,7 +52,7 @@
       IdentifiedUser caller,
       MergeTip mergeTip,
       CommitStatus commitStatus,
-      RequestId submissionId,
+      SubmissionId submissionId,
       SubmitInput submitInput,
       SubmoduleOp submoduleOp,
       boolean dryrun)
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 79f062d..cc40a30 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -453,7 +453,7 @@
     Change c = ctx.getChange();
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
-    c.setSubmissionId(args.submissionId.toStringForStorage());
+    c.setSubmissionId(args.submissionId.toString());
 
     // TODO(dborowitz): We need to be able to change the author of the message,
     // which is not the user from the update context. addMergedMessage was able
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index ce16706..6b1d71a 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Throwables;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
@@ -34,6 +35,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSet.Id;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -52,8 +54,8 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.LimitExceededException;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.TooManyUpdatesException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
@@ -128,23 +130,23 @@
 
     try {
       List<ListenableFuture<?>> indexFutures = new ArrayList<>();
-      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
       try {
         for (BatchUpdate u : updates) {
           u.executeUpdateRepo();
         }
         listener.afterUpdateRepos();
         for (BatchUpdate u : updates) {
-          handles.add(u.executeChangeOps(dryrun));
+          changesHandles.add(u.executeChangeOps(dryrun));
         }
-        for (ChangesHandle h : handles) {
+        for (ChangesHandle h : changesHandles) {
           h.execute();
           indexFutures.addAll(h.startIndexFutures());
         }
         listener.afterUpdateRefs();
         listener.afterUpdateChanges();
       } finally {
-        for (ChangesHandle h : handles) {
+        for (ChangesHandle h : changesHandles) {
           h.close();
         }
       }
@@ -181,7 +183,7 @@
   private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
     // Convert common non-REST exception types with user-visible messages to corresponding REST
     // exception types.
-    if (e instanceof InvalidChangeOperationException || e instanceof TooManyUpdatesException) {
+    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
       throw new ResourceConflictException(e.getMessage(), e);
     } else if (e instanceof NoSuchChangeException
         || e instanceof NoSuchRefException
@@ -256,30 +258,55 @@
 
   private class ChangeContextImpl extends ContextImpl implements ChangeContext {
     private final ChangeNotes notes;
-    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    /**
+     * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
+     * PatchSet.Id only for convenience.
+     */
+    private final Map<PatchSet.Id, ChangeUpdate> defaultUpdates;
+
+    /**
+     * Updates where the caller allowed us to combine potentially multiple adjustments into a single
+     * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
+     * patch set.
+     */
+    private final ListMultimap<Id, ChangeUpdate> distinctUpdates;
 
     private boolean deleted;
 
     ChangeContextImpl(ChangeNotes notes) {
       this.notes = requireNonNull(notes);
-      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+      defaultUpdates = new TreeMap<>(comparing(PatchSet.Id::get));
+      distinctUpdates = ArrayListMultimap.create();
     }
 
     @Override
     public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      ChangeUpdate u = updates.get(psId);
+      ChangeUpdate u = defaultUpdates.get(psId);
       if (u == null) {
-        u = changeUpdateFactory.create(notes, user, when);
-        if (newChanges.containsKey(notes.getChangeId())) {
-          u.setAllowWriteToNewRef(true);
-        }
-        u.setPatchSetId(psId);
-        updates.put(psId, u);
+        u = getNewChangeUpdate(psId);
+        defaultUpdates.put(psId, u);
       }
       return u;
     }
 
     @Override
+    public ChangeUpdate getDistinctUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = getNewChangeUpdate(psId);
+      distinctUpdates.put(psId, u);
+      return u;
+    }
+
+    private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = changeUpdateFactory.create(notes, user, when);
+      if (newChanges.containsKey(notes.getChangeId())) {
+        u.setAllowWriteToNewRef(true);
+      }
+      u.setPatchSetId(psId);
+      return u;
+    }
+
+    @Override
     public ChangeNotes getNotes() {
       return notes;
     }
@@ -571,7 +598,8 @@
         handle.setResult(id, ChangeResult.SKIPPED);
         continue;
       }
-      ctx.updates.values().forEach(handle.manager::add);
+      ctx.defaultUpdates.values().forEach(handle.manager::add);
+      ctx.distinctUpdates.values().forEach(handle.manager::add);
       if (ctx.deleted) {
         logDebug("Change %s was deleted", id);
         handle.manager.deleteChange(id);
diff --git a/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
index bd6d90b..5a53e2a 100644
--- a/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/java/com/google/gerrit/server/update/ChangeContext.java
@@ -30,7 +30,7 @@
  */
 public interface ChangeContext extends Context {
   /**
-   * Get an update for this change at a given patch set.
+   * Get the first update for this change at a given patch set.
    *
    * <p>A single operation can modify changes at different patch sets. Commits in the NoteDb graph
    * within this update are created in patch set order.
@@ -43,6 +43,16 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
+   * Gets a new ChangeUpdate for this change at a given patch set.
+   *
+   * <p>To get the current patch set ID, use {@link com.google.gerrit.server.PatchSetUtil#current}.
+   *
+   * @param psId patch set ID.
+   * @return handle for change updates.
+   */
+  ChangeUpdate getDistinctUpdate(PatchSet.Id psId);
+
+  /**
    * Get the up-to-date notes for this change.
    *
    * <p>The change data is read within the same transaction that {@link
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index bea3867..e13cab1 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -28,14 +28,11 @@
 import com.github.rholder.retry.WaitStrategy;
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Throwables;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.metrics.Counter1;
-import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.metrics.Counter3;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
@@ -45,10 +42,18 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.RetryableAction.Action;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
+import com.google.gerrit.server.update.RetryableChangeAction.ChangeAction;
+import com.google.gerrit.server.update.RetryableIndexQueryAction.IndexQueryAction;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.time.Duration;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ExecutionException;
@@ -60,24 +65,6 @@
 public class RetryHelper {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @FunctionalInterface
-  public interface ChangeAction<T> {
-    T call(BatchUpdate.Factory batchUpdateFactory) throws Exception;
-  }
-
-  @FunctionalInterface
-  public interface Action<T> {
-    T call() throws Exception;
-  }
-
-  public enum ActionType {
-    ACCOUNT_UPDATE,
-    CHANGE_UPDATE,
-    GROUP_UPDATE,
-    INDEX_QUERY,
-    PLUGIN_UPDATE
-  }
-
   /**
    * Options for retrying a single operation.
    *
@@ -100,7 +87,7 @@
     @Nullable
     abstract Duration timeout();
 
-    abstract Optional<Class<?>> caller();
+    abstract Optional<String> actionName();
 
     abstract Optional<Predicate<Throwable>> retryWithTrace();
 
@@ -112,7 +99,7 @@
 
       public abstract Builder timeout(Duration timeout);
 
-      public abstract Builder caller(Class<?> caller);
+      public abstract Builder actionName(String caller);
 
       public abstract Builder retryWithTrace(Predicate<Throwable> exceptionPredicate);
 
@@ -125,15 +112,28 @@
   @VisibleForTesting
   @Singleton
   public static class Metrics {
-    final Counter1<ActionType> attemptCounts;
-    final Counter1<ActionType> timeoutCount;
-    final Counter2<ActionType, String> autoRetryCount;
-    final Counter2<ActionType, String> failuresOnAutoRetryCount;
+    final Counter3<String, String, String> attemptCounts;
+    final Counter3<String, String, String> timeoutCount;
+    final Counter3<String, String, String> autoRetryCount;
+    final Counter3<String, String, String> failuresOnAutoRetryCount;
 
     @Inject
     Metrics(MetricMaker metricMaker) {
-      Field<ActionType> actionTypeField =
-          Field.ofEnum(ActionType.class, "action_type", Metadata.Builder::actionType).build();
+      Field<String> actionTypeField =
+          Field.ofString("action_type", Metadata.Builder::actionType).build();
+      Field<String> operationNameField =
+          Field.ofString("operation_name", Metadata.Builder::operationName)
+              .description("The name of the operation that was retried.")
+              .build();
+      Field<String> originalCauseField =
+          Field.ofString("cause", Metadata.Builder::cause)
+              .description("The original cause that triggered the retry.")
+              .build();
+      Field<String> causeField =
+          Field.ofString("cause", Metadata.Builder::cause)
+              .description("The cause for the retry.")
+              .build();
+
       attemptCounts =
           metricMaker.newCounter(
               "action/retry_attempt_count",
@@ -142,7 +142,9 @@
                           + " (0 == single attempt, no retry)")
                   .setCumulative()
                   .setUnit("attempts"),
-              actionTypeField);
+              actionTypeField,
+              operationNameField,
+              originalCauseField);
       timeoutCount =
           metricMaker.newCounter(
               "action/retry_timeout_count",
@@ -150,7 +152,9 @@
                       "Number of action executions of RetryHelper that ultimately timed out")
                   .setCumulative()
                   .setUnit("timeouts"),
-              actionTypeField);
+              actionTypeField,
+              operationNameField,
+              originalCauseField);
       autoRetryCount =
           metricMaker.newCounter(
               "action/auto_retry_count",
@@ -158,9 +162,8 @@
                   .setCumulative()
                   .setUnit("retries"),
               actionTypeField,
-              Field.ofString("operation_name", Metadata.Builder::operationName)
-                  .description("The name of the operation that was retried.")
-                  .build());
+              operationNameField,
+              causeField);
       failuresOnAutoRetryCount =
           metricMaker.newCounter(
               "action/failures_on_auto_retry_count",
@@ -168,9 +171,8 @@
                   .setCumulative()
                   .setUnit("failures"),
               actionTypeField,
-              Field.ofString("operation_name", Metadata.Builder::operationName)
-                  .description("The name of the operation that was retried.")
-                  .build());
+              operationNameField,
+              causeField);
     }
   }
 
@@ -178,14 +180,14 @@
     return new AutoValue_RetryHelper_Options.Builder();
   }
 
-  private static Options defaults() {
-    return options().build();
-  }
-
+  private final Config cfg;
   private final Metrics metrics;
   private final BatchUpdate.Factory updateFactory;
+  private final Provider<InternalAccountQuery> internalAccountQuery;
+  private final Provider<InternalChangeQuery> internalChangeQuery;
   private final PluginSetContext<ExceptionHook> exceptionHooks;
-  private final Map<ActionType, Duration> defaultTimeouts;
+  private final Duration defaultTimeout;
+  private final Map<String, Duration> defaultTimeouts;
   private final WaitStrategy waitStrategy;
   @Nullable private final Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup;
   private final boolean retryWithTraceOnFailure;
@@ -195,8 +197,17 @@
       @GerritServerConfig Config cfg,
       Metrics metrics,
       PluginSetContext<ExceptionHook> exceptionHooks,
-      BatchUpdate.Factory updateFactory) {
-    this(cfg, metrics, updateFactory, exceptionHooks, null);
+      BatchUpdate.Factory updateFactory,
+      Provider<InternalAccountQuery> internalAccountQuery,
+      Provider<InternalChangeQuery> internalChangeQuery) {
+    this(
+        cfg,
+        metrics,
+        updateFactory,
+        internalAccountQuery,
+        internalChangeQuery,
+        exceptionHooks,
+        null);
   }
 
   @VisibleForTesting
@@ -204,21 +215,25 @@
       @GerritServerConfig Config cfg,
       Metrics metrics,
       BatchUpdate.Factory updateFactory,
+      Provider<InternalAccountQuery> internalAccountQuery,
+      Provider<InternalChangeQuery> internalChangeQuery,
       PluginSetContext<ExceptionHook> exceptionHooks,
       @Nullable Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup) {
+    this.cfg = cfg;
     this.metrics = metrics;
     this.updateFactory = updateFactory;
+    this.internalAccountQuery = internalAccountQuery;
+    this.internalChangeQuery = internalChangeQuery;
     this.exceptionHooks = exceptionHooks;
-
-    Duration defaultTimeout =
+    this.defaultTimeout =
         Duration.ofMillis(
             cfg.getTimeUnit("retry", null, "timeout", SECONDS.toMillis(20), MILLISECONDS));
-    this.defaultTimeouts = Maps.newEnumMap(ActionType.class);
+    this.defaultTimeouts = new HashMap<>();
     Arrays.stream(ActionType.values())
         .forEach(
             at ->
                 defaultTimeouts.put(
-                    at,
+                    at.name(),
                     Duration.ofMillis(
                         cfg.getTimeUnit(
                             "retry",
@@ -237,54 +252,182 @@
     this.retryWithTraceOnFailure = cfg.getBoolean("retry", "retryWithTraceOnFailure", false);
   }
 
-  public Duration getDefaultTimeout(ActionType actionType) {
-    return defaultTimeouts.get(actionType);
+  /**
+   * Creates an action that is executed with retrying when called.
+   *
+   * <p>This method allows to use a custom action type. If the action type is one of {@link
+   * ActionType} the usage of {@link #action(ActionType, String, Action)} is preferred.
+   *
+   * <p>The action type is used as metric bucket and decides which default timeout is used.
+   *
+   * @param actionType the type of the action, used as metric bucket
+   * @param actionName the name of the action, used as metric bucket
+   * @param action the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableAction#call()} to execute
+   *     the action
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public <T> RetryableAction<T> action(String actionType, String actionName, Action<T> action) {
+    return new RetryableAction<>(this, actionType, actionName, action);
   }
 
-  public <T> T execute(
-      ActionType actionType, Action<T> action, Predicate<Throwable> exceptionPredicate)
-      throws Exception {
-    return execute(actionType, action, defaults(), exceptionPredicate);
+  /**
+   * Creates an action that is executed with retrying when called.
+   *
+   * @param actionType the type of the action, used as metric bucket
+   * @param actionName the name of the action, used as metric bucket
+   * @param action the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableAction#call()} to execute
+   *     the action
+   */
+  public <T> RetryableAction<T> action(ActionType actionType, String actionName, Action<T> action) {
+    return new RetryableAction<>(this, actionType, actionName, action);
   }
 
-  public <T> T execute(
-      ActionType actionType,
-      Action<T> action,
-      Options opts,
-      Predicate<Throwable> exceptionPredicate)
-      throws Exception {
-    try {
-      return executeWithAttemptAndTimeoutCount(actionType, action, opts, exceptionPredicate);
-    } catch (Throwable t) {
-      Throwables.throwIfUnchecked(t);
-      Throwables.throwIfInstanceOf(t, Exception.class);
-      throw new IllegalStateException(t);
+  /**
+   * Creates an action for updating an account that is executed with retrying when called.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param action the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableAction#call()} to execute
+   *     the action
+   */
+  public <T> RetryableAction<T> accountUpdate(String actionName, Action<T> action) {
+    return new RetryableAction<>(this, ActionType.ACCOUNT_UPDATE, actionName, action);
+  }
+
+  /**
+   * Creates an action for updating a change that is executed with retrying when called.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param action the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableAction#call()} to execute
+   *     the action
+   */
+  public <T> RetryableAction<T> changeUpdate(String actionName, Action<T> action) {
+    return new RetryableAction<>(this, ActionType.CHANGE_UPDATE, actionName, action);
+  }
+
+  /**
+   * Creates an action for updating a change that is executed with retrying when called.
+   *
+   * <p>The change action gets a {@link BatchUpdate.Factory} provided that can be used to update the
+   * change.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param changeAction the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableChangeAction#call()} to
+   *     execute the action
+   */
+  public <T> RetryableChangeAction<T> changeUpdate(
+      String actionName, ChangeAction<T> changeAction) {
+    return new RetryableChangeAction<>(this, updateFactory, actionName, changeAction);
+  }
+
+  /**
+   * Creates an action for updating a group that is executed with retrying when called.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param action the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableAction#call()} to execute
+   *     the action
+   */
+  public <T> RetryableAction<T> groupUpdate(String actionName, Action<T> action) {
+    return new RetryableAction<>(this, ActionType.GROUP_UPDATE, actionName, action);
+  }
+
+  /**
+   * Creates an action for updating of plugin-specific data that is executed with retrying when
+   * called.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param action the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableAction#call()} to execute
+   *     the action
+   */
+  public <T> RetryableAction<T> pluginUpdate(String actionName, Action<T> action) {
+    return new RetryableAction<>(this, ActionType.PLUGIN_UPDATE, actionName, action);
+  }
+
+  /**
+   * Creates an action for querying the account index that is executed with retrying when called.
+   *
+   * <p>The index query action gets a {@link InternalAccountQuery} provided that can be used to
+   * query the account index.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param indexQueryAction the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableIndexQueryAction#call()} to
+   *     execute the action
+   */
+  public <T> RetryableIndexQueryAction<InternalAccountQuery, T> accountIndexQuery(
+      String actionName, IndexQueryAction<T, InternalAccountQuery> indexQueryAction) {
+    return new RetryableIndexQueryAction<>(
+        this, internalAccountQuery, actionName, indexQueryAction);
+  }
+
+  /**
+   * Creates an action for querying the change index that is executed with retrying when called.
+   *
+   * <p>The index query action gets a {@link InternalChangeQuery} provided that can be used to query
+   * the change index.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param indexQueryAction the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableIndexQueryAction#call()} to
+   *     execute the action
+   */
+  public <T> RetryableIndexQueryAction<InternalChangeQuery, T> changeIndexQuery(
+      String actionName, IndexQueryAction<T, InternalChangeQuery> indexQueryAction) {
+    return new RetryableIndexQueryAction<>(this, internalChangeQuery, actionName, indexQueryAction);
+  }
+
+  /**
+   * Returns the default timeout for an action type.
+   *
+   * <p>The default timeout for an action type is defined by the 'retry.<action-type>.timeout'
+   * parameter in gerrit.config. If this parameter is not set the value from the 'retry.timeout'
+   * parameter is used (if this is also not set we fall back to to a hard-coded timeout of 20s).
+   *
+   * <p>Callers can overwrite the default timeout by setting another timeout in the {@link Options},
+   * see {@link Options#timeout()}.
+   *
+   * @param actionType the action type for which the default timeout should be retrieved
+   * @return the default timeout for the given action type
+   */
+  Duration getDefaultTimeout(String actionType) {
+    Duration timeout = defaultTimeouts.get(actionType);
+    if (timeout != null) {
+      return timeout;
     }
+    return readDefaultTimeoutFromConfig(actionType);
   }
 
-  public <T> T execute(ChangeAction<T> changeAction) throws RestApiException, UpdateException {
-    return execute(changeAction, defaults());
-  }
-
-  public <T> T execute(ChangeAction<T> changeAction, Options opts)
-      throws RestApiException, UpdateException {
-    try {
-      return execute(
-          ActionType.CHANGE_UPDATE,
-          () -> changeAction.call(updateFactory),
-          opts,
-          t -> {
-            if (t instanceof UpdateException) {
-              t = t.getCause();
-            }
-            return t instanceof LockFailureException;
-          });
-    } catch (Throwable t) {
-      Throwables.throwIfUnchecked(t);
-      Throwables.throwIfInstanceOf(t, UpdateException.class);
-      Throwables.throwIfInstanceOf(t, RestApiException.class);
-      throw new UpdateException(t);
+  /**
+   * Thread-safe method to read and cache a default timeout from gerrit.config.
+   *
+   * <p>After reading the default timeout from gerrit.config it is cached in the {@link
+   * #defaultTimeouts} map, so that it's read only once.
+   *
+   * @param actionType the action type for which the default timeout should be retrieved
+   * @return the default timeout for the given action type
+   */
+  private synchronized Duration readDefaultTimeoutFromConfig(String actionType) {
+    Duration timeout = defaultTimeouts.get(actionType);
+    if (timeout != null) {
+      // some other thread has read the default timeout from the config in the meantime
+      return timeout;
     }
+    timeout =
+        Duration.ofMillis(
+            cfg.getTimeUnit(
+                "retry",
+                actionType,
+                "timeout",
+                SECONDS.toMillis(defaultTimeout.getSeconds()),
+                MILLISECONDS));
+    defaultTimeouts.put(actionType, timeout);
+    return timeout;
   }
 
   /**
@@ -298,11 +441,8 @@
    * @throws Throwable any error or exception that made the action fail, callers are expected to
    *     catch and inspect this Throwable to decide carefully whether it should be re-thrown
    */
-  private <T> T executeWithAttemptAndTimeoutCount(
-      ActionType actionType,
-      Action<T> action,
-      Options opts,
-      Predicate<Throwable> exceptionPredicate)
+  <T> T execute(
+      String actionType, Action<T> action, Options opts, Predicate<Throwable> exceptionPredicate)
       throws Throwable {
     MetricListener listener = new MetricListener();
     try (TraceContext traceContext = TraceContext.open()) {
@@ -317,8 +457,11 @@
                   return true;
                 }
 
+                String actionName = opts.actionName().orElse("N/A");
+
                 // Exception hooks may identify additional exceptions for retry.
-                if (exceptionHooks.stream().anyMatch(h -> h.shouldRetry(t))) {
+                if (exceptionHooks.stream()
+                    .anyMatch(h -> h.shouldRetry(actionType, actionName, t))) {
                   return true;
                 }
 
@@ -327,14 +470,21 @@
                 if (retryWithTraceOnFailure
                     && opts.retryWithTrace().isPresent()
                     && opts.retryWithTrace().get().test(t)) {
-                  String caller = opts.caller().map(Class::getSimpleName).orElse("N/A");
+                  // Exception hooks may identify exceptions for which retrying with trace should be
+                  // skipped.
+                  if (exceptionHooks.stream()
+                      .anyMatch(h -> h.skipRetryWithTrace(actionType, actionName, t))) {
+                    return false;
+                  }
+
+                  String cause = formatCause(t);
                   if (!traceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
-                    opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
                     logger.atFine().withCause(t).log(
-                        "AutoRetry: %s failed, retry with tracing enabled", caller);
-                    metrics.autoRetryCount.increment(actionType, caller);
+                        "AutoRetry: %s failed, retry with tracing enabled", actionName);
+                    opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
+                    metrics.autoRetryCount.increment(actionType, actionName, cause);
                     return true;
                   }
 
@@ -342,40 +492,78 @@
                   // enabled and it failed again. Log the failure so that admin can see if it
                   // differs from the failure that triggered the retry.
                   logger.atFine().withCause(t).log(
-                      "AutoRetry: auto-retry of %s has failed", caller);
-                  metrics.failuresOnAutoRetryCount.increment(actionType, caller);
+                      "AutoRetry: auto-retry of %s has failed", actionName);
+                  metrics.failuresOnAutoRetryCount.increment(actionType, actionName, cause);
                   return false;
                 }
 
                 return false;
               });
       retryerBuilder.withRetryListener(listener);
-      return executeWithTimeoutCount(actionType, action, retryerBuilder.build());
+      return executeWithTimeoutCount(actionType, action, opts, retryerBuilder.build(), listener);
     } finally {
       if (listener.getAttemptCount() > 1) {
         logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
-        metrics.attemptCounts.incrementBy(actionType, listener.getAttemptCount() - 1);
+        metrics.attemptCounts.incrementBy(
+            actionType,
+            opts.actionName().orElse("N/A"),
+            listener.getOriginalCause().map(this::formatCause).orElse("_unknown"),
+            listener.getAttemptCount() - 1);
       }
     }
   }
 
+  public String formatCause(Throwable t) {
+    while ((t instanceof UpdateException
+            || t instanceof StorageException
+            || t instanceof ExecutionException)
+        && t.getCause() != null) {
+      t = t.getCause();
+    }
+
+    Optional<String> formattedCause = getFormattedCauseFromHooks(t);
+    if (formattedCause.isPresent()) {
+      return formattedCause.get();
+    }
+
+    return t.getClass().getSimpleName();
+  }
+
+  private Optional<String> getFormattedCauseFromHooks(Throwable t) {
+    return exceptionHooks.stream()
+        .map(h -> h.formatCause(t))
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .findFirst();
+  }
+
   /**
    * Executes an action and records the timeout as metric.
    *
    * @param actionType the type of the action
    * @param action the action which should be executed and retried on failure
+   * @param opts options for retrying the action on failure
    * @param retryer the retryer
+   * @param listener metric listener
    * @return the result of executing the action
    * @throws Throwable any error or exception that made the action fail, callers are expected to
    *     catch and inspect this Throwable to decide carefully whether it should be re-thrown
    */
-  private <T> T executeWithTimeoutCount(ActionType actionType, Action<T> action, Retryer<T> retryer)
+  private <T> T executeWithTimeoutCount(
+      String actionType,
+      Action<T> action,
+      Options opts,
+      Retryer<T> retryer,
+      MetricListener listener)
       throws Throwable {
     try {
       return retryer.call(action::call);
     } catch (ExecutionException | RetryException e) {
       if (e instanceof RetryException) {
-        metrics.timeoutCount.increment(actionType);
+        metrics.timeoutCount.increment(
+            actionType,
+            opts.actionName().orElse("N/A"),
+            listener.getOriginalCause().map(this::formatCause).orElse("_unknown"));
       }
       if (e.getCause() != null) {
         throw e.getCause();
@@ -385,7 +573,7 @@
   }
 
   private <O> RetryerBuilder<O> createRetryerBuilder(
-      ActionType actionType, Options opts, Predicate<Throwable> exceptionPredicate) {
+      String actionType, Options opts, Predicate<Throwable> exceptionPredicate) {
     RetryerBuilder<O> retryerBuilder =
         RetryerBuilder.<O>newBuilder().retryIfException(exceptionPredicate::test);
     if (opts.listener() != null) {
@@ -407,18 +595,27 @@
 
   private static class MetricListener implements RetryListener {
     private long attemptCount;
+    private Optional<Throwable> originalCause;
 
     MetricListener() {
       attemptCount = 1;
+      originalCause = Optional.empty();
     }
 
     @Override
     public <V> void onRetry(Attempt<V> attempt) {
       attemptCount = attempt.getAttemptNumber();
+      if (attemptCount == 1 && attempt.hasException()) {
+        originalCause = Optional.of(attempt.getExceptionCause());
+      }
     }
 
     long getAttemptCount() {
       return attemptCount;
     }
+
+    Optional<Throwable> getOriginalCause() {
+      return originalCause;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/update/RetryableAction.java b/java/com/google/gerrit/server/update/RetryableAction.java
new file mode 100644
index 0000000..167b209
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RetryableAction.java
@@ -0,0 +1,183 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static java.util.Objects.requireNonNull;
+
+import com.github.rholder.retry.RetryListener;
+import com.google.common.base.Throwables;
+import com.google.gerrit.server.ExceptionHook;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * An action that is executed with retrying.
+ *
+ * <p>Instances of this class are created via {@link RetryHelper} (see {@link
+ * RetryHelper#action(ActionType, String, Action)}, {@link RetryHelper#accountUpdate(String,
+ * Action)}, {@link RetryHelper#changeUpdate(String, Action)}, {@link
+ * RetryHelper#groupUpdate(String, Action)}, {@link RetryHelper#pluginUpdate(String, Action)}).
+ *
+ * <p>Which exceptions cause a retry is controlled by {@link ExceptionHook#shouldRetry(String,
+ * String, Throwable)}. In addition callers can specify additional exception that should cause a
+ * retry via {@link #retryOn(Predicate)}.
+ */
+public class RetryableAction<T> {
+  /**
+   * Type of an retryable action.
+   *
+   * <p>The action type is used for two purposes:
+   *
+   * <ul>
+   *   <li>to determine the default timeout for executing the action (see {@link
+   *       RetryHelper#getDefaultTimeout(String)})
+   *   <li>as bucket for all retry metrics (see {@link RetryHelper.Metrics})
+   * </ul>
+   */
+  public enum ActionType {
+    ACCOUNT_UPDATE,
+    CHANGE_UPDATE,
+    GIT_UPDATE,
+    GROUP_UPDATE,
+    INDEX_QUERY,
+    PLUGIN_UPDATE,
+    REST_READ_REQUEST,
+    REST_WRITE_REQUEST,
+  }
+
+  @FunctionalInterface
+  public interface Action<T> {
+    T call() throws Exception;
+  }
+
+  private final RetryHelper retryHelper;
+  private final String actionType;
+  private final Action<T> action;
+  private final RetryHelper.Options.Builder options = RetryHelper.options();
+  private final List<Predicate<Throwable>> exceptionPredicates = new ArrayList<>();
+
+  RetryableAction(
+      RetryHelper retryHelper, ActionType actionType, String actionName, Action<T> action) {
+    this(retryHelper, requireNonNull(actionType, "actionType").name(), actionName, action);
+  }
+
+  RetryableAction(RetryHelper retryHelper, String actionType, String actionName, Action<T> action) {
+    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
+    this.actionType = requireNonNull(actionType, "actionType");
+    this.action = requireNonNull(action, "action");
+    options.actionName(requireNonNull(actionName, "actionName"));
+  }
+
+  /**
+   * Adds an additional condition that should trigger retries.
+   *
+   * <p>For some exceptions retrying is enabled globally (see {@link
+   * ExceptionHook#shouldRetry(String, String, Throwable)}). Conditions for those exceptions do not
+   * need to be specified here again.
+   *
+   * <p>This method can be invoked multiple times to add further conditions that should trigger
+   * retries.
+   *
+   * @param exceptionPredicate predicate that decides if the action should be retried for a given
+   *     exception
+   * @return this instance to enable chaining of calls
+   */
+  public RetryableAction<T> retryOn(Predicate<Throwable> exceptionPredicate) {
+    exceptionPredicates.add(exceptionPredicate);
+    return this;
+  }
+
+  /**
+   * Sets a condition that should trigger auto-retry with tracing.
+   *
+   * <p>This condition is only relevant if an exception occurs that doesn't trigger (normal) retry.
+   *
+   * <p>Auto-retry with tracing automatically captures traces for unexpected exceptions so that they
+   * can be investigated.
+   *
+   * <p>Every call of this method overwrites any previously set condition for auto-retry with
+   * tracing.
+   *
+   * @param exceptionPredicate predicate that decides if the action should be retried with tracing
+   *     for a given exception
+   * @return this instance to enable chaining of calls
+   */
+  public RetryableAction<T> retryWithTrace(Predicate<Throwable> exceptionPredicate) {
+    options.retryWithTrace(exceptionPredicate);
+    return this;
+  }
+
+  /**
+   * Sets a callback that is invoked when auto-retry with tracing is triggered.
+   *
+   * <p>Via the callback callers can find out with trace ID was used for the retry.
+   *
+   * <p>Every call of this method overwrites any previously set trace ID consumer.
+   *
+   * @param traceIdConsumer trace ID consumer
+   * @return this instance to enable chaining of calls
+   */
+  public RetryableAction<T> onAutoTrace(Consumer<String> traceIdConsumer) {
+    options.onAutoTrace(traceIdConsumer);
+    return this;
+  }
+
+  /**
+   * Sets a listener that is invoked when the action is retried.
+   *
+   * <p>Every call of this method overwrites any previously set listener.
+   *
+   * @param retryListener retry listener
+   * @return this instance to enable chaining of calls
+   */
+  public RetryableAction<T> listener(RetryListener retryListener) {
+    options.listener(retryListener);
+    return this;
+  }
+
+  /**
+   * Increases the default timeout by the given multiplier.
+   *
+   * <p>Every call of this method overwrites any previously set timeout.
+   *
+   * @param multiplier multiplier for the default timeout
+   * @return this instance to enable chaining of calls
+   */
+  public RetryableAction<T> defaultTimeoutMultiplier(int multiplier) {
+    options.timeout(retryHelper.getDefaultTimeout(actionType).multipliedBy(multiplier));
+    return this;
+  }
+
+  /**
+   * Executes this action with retry.
+   *
+   * @return the result of the action
+   */
+  public T call() throws Exception {
+    try {
+      return retryHelper.execute(
+          actionType,
+          action,
+          options.build(),
+          t -> exceptionPredicates.stream().anyMatch(p -> p.test(t)));
+    } catch (Throwable t) {
+      Throwables.throwIfUnchecked(t);
+      Throwables.throwIfInstanceOf(t, Exception.class);
+      throw new IllegalStateException(t);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RetryableChangeAction.java b/java/com/google/gerrit/server/update/RetryableChangeAction.java
new file mode 100644
index 0000000..152db2c
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RetryableChangeAction.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.github.rholder.retry.RetryListener;
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * A change action that is executed with retrying.
+ *
+ * <p>Instances of this class are created via {@link RetryHelper#changeUpdate(String,
+ * ChangeAction)}.
+ *
+ * <p>In contrast to normal {@link RetryableAction.Action}s that are called via {@link
+ * RetryableAction} {@link ChangeAction}s get a {@link BatchUpdate.Factory} provided.
+ *
+ * <p>In addition when a change action is called any exception that is not an unchecked exception
+ * and neither {@link UpdateException} nor {@link RestApiException} get wrapped into an {@link
+ * UpdateException}.
+ */
+public class RetryableChangeAction<T> extends RetryableAction<T> {
+  @FunctionalInterface
+  public interface ChangeAction<T> {
+    T call(BatchUpdate.Factory batchUpdateFactory) throws Exception;
+  }
+
+  RetryableChangeAction(
+      RetryHelper retryHelper,
+      BatchUpdate.Factory updateFactory,
+      String actionName,
+      ChangeAction<T> changeAction) {
+    super(
+        retryHelper, ActionType.CHANGE_UPDATE, actionName, () -> changeAction.call(updateFactory));
+  }
+
+  @Override
+  public RetryableChangeAction<T> retryOn(Predicate<Throwable> exceptionPredicate) {
+    super.retryOn(exceptionPredicate);
+    return this;
+  }
+
+  @Override
+  public RetryableChangeAction<T> retryWithTrace(Predicate<Throwable> exceptionPredicate) {
+    super.retryWithTrace(exceptionPredicate);
+    return this;
+  }
+
+  @Override
+  public RetryableChangeAction<T> onAutoTrace(Consumer<String> traceIdConsumer) {
+    super.onAutoTrace(traceIdConsumer);
+    return this;
+  }
+
+  @Override
+  public RetryableChangeAction<T> listener(RetryListener retryListener) {
+    super.listener(retryListener);
+    return this;
+  }
+
+  @Override
+  public RetryableChangeAction<T> defaultTimeoutMultiplier(int multiplier) {
+    super.defaultTimeoutMultiplier(multiplier);
+    return this;
+  }
+
+  @Override
+  public T call() throws UpdateException, RestApiException {
+    try {
+      return super.call();
+    } catch (Throwable t) {
+      Throwables.throwIfUnchecked(t);
+      Throwables.throwIfInstanceOf(t, UpdateException.class);
+      Throwables.throwIfInstanceOf(t, RestApiException.class);
+      throw new UpdateException(t);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java b/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
new file mode 100644
index 0000000..cf733a6
--- /dev/null
+++ b/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.github.rholder.retry.RetryListener;
+import com.google.common.base.Throwables;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.InternalQuery;
+import com.google.inject.Provider;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * An action to query an index that is executed with retrying.
+ *
+ * <p>Instances of this class are created via {@link RetryHelper#accountIndexQuery(String,
+ * IndexQueryAction)} and {@link RetryHelper#changeIndexQuery(String, IndexQueryAction)}.
+ *
+ * <p>In contrast to normal {@link RetryableAction.Action}s that are called via {@link
+ * RetryableAction} {@link IndexQueryAction}s get a {@link InternalQuery} provided.
+ *
+ * <p>In addition when an index query action is called any exception that is not an unchecked
+ * exception gets wrapped into an {@link StorageException}.
+ */
+public class RetryableIndexQueryAction<Q extends InternalQuery<?, Q>, T>
+    extends RetryableAction<T> {
+  @FunctionalInterface
+  public interface IndexQueryAction<T, Q> {
+    T call(Q internalQuery) throws Exception;
+  }
+
+  RetryableIndexQueryAction(
+      RetryHelper retryHelper,
+      Provider<Q> internalQuery,
+      String actionName,
+      IndexQueryAction<T, Q> indexQuery) {
+    super(
+        retryHelper,
+        ActionType.INDEX_QUERY,
+        actionName,
+        () -> indexQuery.call(internalQuery.get()));
+  }
+
+  @Override
+  public RetryableIndexQueryAction<Q, T> retryOn(Predicate<Throwable> exceptionPredicate) {
+    super.retryOn(exceptionPredicate);
+    return this;
+  }
+
+  @Override
+  public RetryableIndexQueryAction<Q, T> retryWithTrace(Predicate<Throwable> exceptionPredicate) {
+    super.retryWithTrace(exceptionPredicate);
+    return this;
+  }
+
+  @Override
+  public RetryableIndexQueryAction<Q, T> onAutoTrace(Consumer<String> traceIdConsumer) {
+    super.onAutoTrace(traceIdConsumer);
+    return this;
+  }
+
+  @Override
+  public RetryableIndexQueryAction<Q, T> listener(RetryListener retryListener) {
+    super.listener(retryListener);
+    return this;
+  }
+
+  @Override
+  public RetryableIndexQueryAction<Q, T> defaultTimeoutMultiplier(int multiplier) {
+    super.defaultTimeoutMultiplier(multiplier);
+    return this;
+  }
+
+  @Override
+  public T call() {
+    try {
+      return super.call();
+    } catch (Throwable t) {
+      Throwables.throwIfUnchecked(t);
+      throw new StorageException(t);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
deleted file mode 100644
index 96c2ed3..0000000
--- a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.common.base.Throwables;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
-import com.google.gerrit.extensions.restapi.RestResource;
-import java.util.concurrent.atomic.AtomicReference;
-
-public abstract class RetryingRestCollectionModifyView<
-        P extends RestResource, C extends RestResource, I, O>
-    implements RestCollectionModifyView<P, C, I> {
-  private final RetryHelper retryHelper;
-
-  protected RetryingRestCollectionModifyView(RetryHelper retryHelper) {
-    this.retryHelper = retryHelper;
-  }
-
-  @Override
-  public final Response<O> apply(P parentResource, I input)
-      throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    AtomicReference<String> traceId = new AtomicReference<>(null);
-    try {
-      RetryHelper.Options retryOptions =
-          RetryHelper.options()
-              .caller(getClass())
-              .retryWithTrace(t -> !(t instanceof RestApiException))
-              .onAutoTrace(traceId::set)
-              .build();
-      return retryHelper
-          .execute((updateFactory) -> applyImpl(updateFactory, parentResource, input), retryOptions)
-          .traceId(traceId.get());
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      return Response.<O>internalServerError(e).traceId(traceId.get());
-    }
-  }
-
-  protected abstract Response<O> applyImpl(
-      BatchUpdate.Factory updateFactory, P parentResource, I input) throws Exception;
-}
diff --git a/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
deleted file mode 100644
index 275dc55..0000000
--- a/java/com/google/gerrit/server/update/RetryingRestModifyView.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.common.base.Throwables;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.RestResource;
-import java.util.concurrent.atomic.AtomicReference;
-
-public abstract class RetryingRestModifyView<R extends RestResource, I, O>
-    implements RestModifyView<R, I> {
-  private final RetryHelper retryHelper;
-
-  protected RetryingRestModifyView(RetryHelper retryHelper) {
-    this.retryHelper = retryHelper;
-  }
-
-  @Override
-  public final Response<O> apply(R resource, I input) throws RestApiException {
-    AtomicReference<String> traceId = new AtomicReference<>(null);
-    try {
-      RetryHelper.Options retryOptions =
-          RetryHelper.options()
-              .caller(getClass())
-              .retryWithTrace(t -> !(t instanceof RestApiException))
-              .onAutoTrace(traceId::set)
-              .build();
-      return retryHelper
-          .execute((updateFactory) -> applyImpl(updateFactory, resource, input), retryOptions)
-          .traceId(traceId.get());
-    } catch (Exception e) {
-      Throwables.throwIfInstanceOf(e, RestApiException.class);
-      return Response.<O>internalServerError(e).traceId(traceId.get());
-    }
-  }
-
-  protected abstract Response<O> applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
-      throws Exception;
-}
diff --git a/java/com/google/gerrit/server/validators/ValidationException.java b/java/com/google/gerrit/server/validators/ValidationException.java
index 53ded1f..9562e88 100644
--- a/java/com/google/gerrit/server/validators/ValidationException.java
+++ b/java/com/google/gerrit/server/validators/ValidationException.java
@@ -14,6 +14,11 @@
 
 package com.google.gerrit.server.validators;
 
+/**
+ * Exception to be thrown either directly or subclassed indicating that we failed to validate a Git
+ * operation. Failures range from internal checks for NoteDb format and consistency to
+ * plugin-provided checks.
+ */
 public class ValidationException extends Exception {
   private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index bc9a0ee..f567a3a 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -32,7 +32,6 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcprov-neverlink",
-        "//lib/commons:codec",
         "//lib/dropwizard:dropwizard-core",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 6c0f3af..916775d 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
@@ -39,7 +40,6 @@
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Set;
-import org.apache.commons.codec.binary.Base64;
 import org.apache.sshd.common.SshException;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
@@ -198,7 +198,8 @@
           }
 
           try {
-            byte[] bin = Base64.decodeBase64(line.getBytes(ISO_8859_1));
+            byte[] bin =
+                BaseEncoding.base64().decode(new String(line.getBytes(ISO_8859_1), ISO_8859_1));
             keys.add(new ByteArrayBuffer(bin).getRawPublicKey());
           } catch (RuntimeException | SshException e) {
             logBadKey(path, line, e);
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index 39366f0..eac9737 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -28,12 +29,10 @@
 import java.security.interfaces.DSAPublicKey;
 import java.security.interfaces.RSAPublicKey;
 import java.security.spec.InvalidKeySpecException;
-import org.apache.commons.codec.binary.Base64;
 import org.apache.sshd.common.SshException;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.session.ServerSession;
-import org.eclipse.jgit.lib.Constants;
 
 /** Utilities to support SSH operations. */
 public class SshUtil {
@@ -53,7 +52,7 @@
       if (s == null) {
         throw new InvalidKeySpecException("No key string");
       }
-      final byte[] bin = Base64.decodeBase64(Constants.encodeASCII(s));
+      final byte[] bin = BaseEncoding.base64().decode(s);
       return new ByteArrayBuffer(bin).getRawPublicKey();
     } catch (RuntimeException | SshException e) {
       throw new InvalidKeySpecException("Cannot parse key", e);
@@ -91,8 +90,7 @@
       }
 
       final PublicKey key =
-          new ByteArrayBuffer(Base64.decodeBase64(Constants.encodeASCII(strBuf.toString())))
-              .getRawPublicKey();
+          new ByteArrayBuffer(BaseEncoding.base64().decode(strBuf.toString())).getRawPublicKey();
       if (key instanceof RSAPublicKey) {
         strBuf.insert(0, KeyPairProvider.SSH_RSA + " ");
 
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 9f420ed..17f80c0 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -119,7 +119,8 @@
     }
   }
 
-  private GroupResource createGroup() throws Exception {
+  private GroupResource createGroup()
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index 1a60679..80aee01 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.Map;
+import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Ref;
@@ -89,14 +89,14 @@
     try (Repository repo = repoManager.openRepository(projectName);
         ManualRequestContext ctx = requestContext.openAs(userAccountId)) {
       try {
-        Map<String, Ref> refsMap =
+        Collection<Ref> refsMap =
             permissionBackend
                 .user(ctx.getUser())
                 .project(projectName)
                 .filter(repo.getRefDatabase().getRefs(), repo, RefFilterOptions.defaults());
 
-        for (String ref : refsMap.keySet()) {
-          if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
+        for (Ref ref : refsMap) {
+          if (!onlyRefsHeads || ref.getName().startsWith(RefNames.REFS_HEADS)) {
             stdout.println(ref);
           }
         }
diff --git a/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java b/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java
new file mode 100644
index 0000000..e716240
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.CommandName;
+import com.google.gerrit.sshd.Commands;
+import com.google.gerrit.sshd.DispatchCommandProvider;
+
+public class SequenceCommandsModule extends CommandModule {
+
+  @Override
+  protected void configure() {
+    CommandName gerrit = Commands.named("gerrit");
+    CommandName sequence = Commands.named(gerrit, "sequence");
+    command(sequence).toProvider(new DispatchCommandProvider(sequence));
+    command(sequence, SequenceSetCommand.class);
+    command(sequence, SequenceShowCommand.class);
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java b/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
new file mode 100644
index 0000000..197d61c
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Argument;
+
+/** Set sequence value. */
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(name = "set", description = "Set the sequence value")
+final class SequenceSetCommand extends SshCommand {
+  @Argument(index = 0, metaVar = "NAME", required = true, usage = "sequence name")
+  private String name;
+
+  @Argument(index = 1, metaVar = "VALUE", required = true, usage = "sequence value")
+  private int value;
+
+  @Inject Sequences sequences;
+
+  @Override
+  public void run() throws Exception {
+    switch (name) {
+      case "changes":
+        sequences.setChangeIdValue(value);
+        break;
+      case "accounts":
+        sequences.setAccountIdValue(value);
+        break;
+      case "groups":
+        sequences.setGroupIdValue(value);
+        break;
+      default:
+        throw die("Unknown sequence name: " + name);
+    }
+    stdout.print("The value for the " + name + " sequence was set to " + value + ".");
+    stdout.print('\n');
+    stdout.flush();
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java b/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
new file mode 100644
index 0000000..490c7ca
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Argument;
+
+/** Display sequence value. */
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(name = "show", description = "Display the sequence value")
+final class SequenceShowCommand extends SshCommand {
+  @Argument(index = 0, metaVar = "NAME", required = true, usage = "sequence name")
+  private String name;
+
+  @Inject Sequences sequences;
+
+  @Override
+  public void run() throws Exception {
+    int current;
+    switch (name) {
+      case "changes":
+        current = sequences.currentChangeId();
+        break;
+      case "accounts":
+        current = sequences.currentAccountId();
+        break;
+      case "groups":
+        current = sequences.currentGroupId();
+        break;
+      default:
+        throw die("Unknown sequence name: " + name);
+    }
+    stdout.print(current);
+    stdout.print('\n');
+    stdout.flush();
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index db0a481..7e0439f 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -197,7 +197,7 @@
     stdout.flush();
   }
 
-  private Collection<CacheInfo> getCaches() throws Exception {
+  private Collection<CacheInfo> getCaches() {
     @SuppressWarnings("unchecked")
     Map<String, CacheInfo> caches =
         (Map<String, CacheInfo>) listCaches.apply(new ConfigResource()).value();
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 5e2fbea..a76275b 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -13,6 +13,7 @@
         "//lib/mockito",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
index ad985b6..363a07d 100644
--- a/java/com/google/gerrit/testing/GerritServerTests.java
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.testing;
 
+import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfigs;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Rule;
 import org.junit.rules.TestRule;
@@ -23,16 +26,24 @@
 @RunWith(ConfigSuite.class)
 public class GerritServerTests {
   @ConfigSuite.Parameter public Config config;
-
   @ConfigSuite.Name private String configName;
 
   @Rule
   public TestRule testRunner =
-      (base, description) ->
-          new Statement() {
-            @Override
-            public void evaluate() throws Throwable {
-              base.evaluate();
-            }
-          };
+      (base, description) -> {
+        GerritConfig gerritConfig = description.getAnnotation(GerritConfig.class);
+        if (gerritConfig != null) {
+          config = ConfigAnnotationParser.parse(config, gerritConfig);
+        }
+        GerritConfigs gerritConfigs = description.getAnnotation(GerritConfigs.class);
+        if (gerritConfigs != null) {
+          config = ConfigAnnotationParser.parse(config, gerritConfigs);
+        }
+        return new Statement() {
+          @Override
+          public void evaluate() throws Throwable {
+            base.evaluate();
+          }
+        };
+      };
 }
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index 21c49dd..a8ed5be 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testing;
 
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import org.eclipse.jgit.lib.Config;
 
 public class IndexConfig {
@@ -23,7 +24,10 @@
 
   public static Config createFromExistingConfig(Config cfg) {
     cfg.setInt("index", null, "maxPages", 10);
-    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    // To avoid this flakiness indexing mergeable is disabled for the tests as it incurs background
+    // reindex calls.
+    cfg.setEnum(
+        "change", null, "mergeabilityComputationBehavior", MergeabilityComputationBehavior.NEVER);
     cfg.setString("trackingid", "query-bug", "footer", "Bug:");
     cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
     cfg.setString("trackingid", "query-bug", "system", "querytests");
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index b72cca7..deda355 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -16,14 +16,21 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
 
 /** Test helper for dealing with comments/drafts. */
 public class TestCommentHelper {
@@ -44,7 +51,7 @@
   }
 
   public void addDraft(String changeId, String revId, DraftInput in) throws Exception {
-    gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+    gApi.changes().id(changeId).revision(revId).createDraft(in);
   }
 
   public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
@@ -104,4 +111,34 @@
     range.endCharacter = 5;
     return range;
   }
+
+  public static RobotCommentInput createRobotCommentInputWithMandatoryFields(String path) {
+    RobotCommentInput in = new RobotCommentInput();
+    in.robotId = "happyRobot";
+    in.robotRunId = "1";
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = path;
+    return in;
+  }
+
+  public static RobotCommentInput createRobotCommentInput(
+      String path, FixSuggestionInfo... fixSuggestionInfos) {
+    RobotCommentInput in = TestCommentHelper.createRobotCommentInputWithMandatoryFields(path);
+    in.url = "http://www.happy-robot.com";
+    in.properties = new HashMap<>();
+    in.properties.put("key1", "value1");
+    in.properties.put("key2", "value2");
+    in.fixSuggestions = Arrays.asList(fixSuggestionInfos);
+    return in;
+  }
+
+  public void addRobotComment(String targetChangeId, RobotCommentInput robotCommentInput)
+      throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.robotComments =
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
+    reviewInput.message = "robot comment test";
+    gApi.changes().id(targetChangeId).current().review(reviewInput);
+  }
 }
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index bd8cf1a..51c4a3b 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -30,6 +30,20 @@
 import java.io.IOException;
 import org.eclipse.jgit.lib.PersonIdent;
 
+/**
+ * Abstract Prolog predicate for a Git person identity of a change.
+ *
+ * <p>Checks that the terms that are provided as input to this Prolog predicate match a Git person
+ * identity of the change (either author or committer).
+ *
+ * <p>The terms that are provided as input to this Prolog predicate are:
+ *
+ * <ul>
+ *   <li>a user ID term that matches the account ID of the Git person identity
+ *   <li>a string atom that matches the full name of the Git person identity
+ *   <li>a string atom that matches the email of the Git person identity
+ * </ul>
+ */
 abstract class AbstractCommitUserIdentityPredicate extends Predicate.P3 {
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
   private static final SymbolTerm anonymous = SymbolTerm.intern("anonymous");
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
index 4501169..62744f7 100644
--- a/java/gerrit/PRED_change_branch_1.java
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -23,6 +23,16 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the destination branch of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a string atom that
+ * matches the destination branch of the change.
+ *
+ * <pre>
+ *   'change_branch'(-Branch)
+ * </pre>
+ */
 public class PRED_change_branch_1 extends Predicate.P1 {
   public PRED_change_branch_1(Term a1, Operation n) {
     arg1 = a1;
diff --git a/java/gerrit/PRED_change_owner_1.java b/java/gerrit/PRED_change_owner_1.java
index d42c0e1..f6fbb80 100644
--- a/java/gerrit/PRED_change_owner_1.java
+++ b/java/gerrit/PRED_change_owner_1.java
@@ -25,6 +25,16 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the owner of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a user ID term that
+ * matches the account ID of the change owner.
+ *
+ * <pre>
+ *   'change_owner'(user(-ID))
+ * </pre>
+ */
 public class PRED_change_owner_1 extends Predicate.P1 {
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
 
diff --git a/java/gerrit/PRED_change_project_1.java b/java/gerrit/PRED_change_project_1.java
index a973e1c..b2ef109 100644
--- a/java/gerrit/PRED_change_project_1.java
+++ b/java/gerrit/PRED_change_project_1.java
@@ -23,6 +23,16 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the project of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a string atom that
+ * matches the project of the change.
+ *
+ * <pre>
+ *   'change_project'(-Project)
+ * </pre>
+ */
 public class PRED_change_project_1 extends Predicate.P1 {
   public PRED_change_project_1(Term a1, Operation n) {
     arg1 = a1;
diff --git a/java/gerrit/PRED_change_topic_1.java b/java/gerrit/PRED_change_topic_1.java
index 11d737a..f0175ef 100644
--- a/java/gerrit/PRED_change_topic_1.java
+++ b/java/gerrit/PRED_change_topic_1.java
@@ -23,6 +23,16 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the topic of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a string atom that
+ * matches the topic of the change.
+ *
+ * <pre>
+ *   'change_topic'(-Topic)
+ * </pre>
+ */
 public class PRED_change_topic_1 extends Predicate.P1 {
   public PRED_change_topic_1(Term a1, Operation n) {
     arg1 = a1;
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
index 998b30e..3381344 100644
--- a/java/gerrit/PRED_commit_author_3.java
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -21,6 +21,27 @@
 import com.googlecode.prolog_cafe.lang.Term;
 import org.eclipse.jgit.revwalk.RevCommit;
 
+/**
+ * Prolog predicate for the Git author of the current patch set of a change.
+ *
+ * <p>Checks that the terms that are provided as input to this Prolog predicate match the Git author
+ * of the current patch set of the change.
+ *
+ * <p>The terms that are provided as input to this Prolog predicate are:
+ *
+ * <ul>
+ *   <li>a user ID term that matches the account ID of the Git author of the current patch set of
+ *       the change
+ *   <li>a string atom that matches the full name of the Git author of the current patch set of the
+ *       change
+ *   <li>a string atom that matches the email of the Git author of the current patch set of the
+ *       change
+ * </ul>
+ *
+ * <pre>
+ *   'commit_author'(user(-ID), -FullName, -Email)
+ * </pre>
+ */
 public class PRED_commit_author_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_author_3(Term a1, Term a2, Term a3, Operation n) {
     super(a1, a2, a3, n);
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
index 293d8ce..1757336 100644
--- a/java/gerrit/PRED_commit_committer_3.java
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -21,6 +21,27 @@
 import com.googlecode.prolog_cafe.lang.Term;
 import org.eclipse.jgit.revwalk.RevCommit;
 
+/**
+ * Prolog predicate for the Git committer of the current patch set of a change.
+ *
+ * <p>Checks that the terms that are provided as input to this Prolog predicate match the Git
+ * committer of the current patch set of the change.
+ *
+ * <p>The terms that are provided as input to this Prolog predicate are:
+ *
+ * <ul>
+ *   <li>a user ID term that matches the account ID of the Git committer of the current patch set of
+ *       the change
+ *   <li>a string atom that matches the full name of the Git committer of the current patch set of
+ *       the change
+ *   <li>a string atom that matches the email of the Git committer of the current patch set of the
+ *       change
+ * </ul>
+ *
+ * <pre>
+ *   'commit_committer'(user(-ID), -FullName, -Email)
+ * </pre>
+ */
 public class PRED_commit_committer_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_committer_3(Term a1, Term a2, Term a3, Operation n) {
     super(a1, a2, a3, n);
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index eb996d6..3485af6 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -24,7 +24,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
- * Returns the commit message as a symbol
+ * Prolog predicate for the commit message of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a string atom that
+ * matches the commit message of the change.
  *
  * <pre>
  *   'commit_message'(-Msg)
diff --git a/java/gerrit/PRED_project_default_submit_type_1.java b/java/gerrit/PRED_project_default_submit_type_1.java
index d70a9e4..77a0261 100644
--- a/java/gerrit/PRED_project_default_submit_type_1.java
+++ b/java/gerrit/PRED_project_default_submit_type_1.java
@@ -24,6 +24,16 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the default submit type of the project of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a string atom that
+ * matches the default submit type of the change's project.
+ *
+ * <pre>
+ *   'project_default_submit_type'(-SubmitType)
+ * </pre>
+ */
 public class PRED_project_default_submit_type_1 extends Predicate.P1 {
 
   private static final SymbolTerm[] term;
diff --git a/java/gerrit/PRED_pure_revert_1.java b/java/gerrit/PRED_pure_revert_1.java
index 6300a668..19e7b68 100644
--- a/java/gerrit/PRED_pure_revert_1.java
+++ b/java/gerrit/PRED_pure_revert_1.java
@@ -22,7 +22,17 @@
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
 
-/** Checks if change is a pure revert of the change it references in 'revertOf'. */
+/**
+ * Prolog Predicate that checks if the change is a pure revert of the change it references in
+ * 'revertOf'.
+ *
+ * <p>The input is an integer atom where '1' represents a pure revert and '0' represents a non-pure
+ * revert.
+ *
+ * <pre>
+ *   'pure_revert'(-PureRevert)
+ * </pre>
+ */
 public class PRED_pure_revert_1 extends Predicate.P1 {
   public PRED_pure_revert_1(Term a1, Operation n) {
     arg1 = a1;
diff --git a/java/gerrit/PRED_unresolved_comments_count_1.java b/java/gerrit/PRED_unresolved_comments_count_1.java
index d4abcc54..9a1fcca 100644
--- a/java/gerrit/PRED_unresolved_comments_count_1.java
+++ b/java/gerrit/PRED_unresolved_comments_count_1.java
@@ -22,6 +22,16 @@
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the number of unresolved comments of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is an integer atom
+ * that matches the number of unresolved comments of the change.
+ *
+ * <pre>
+ *   'unresolved_comments_count'(-NumberOfUnresolvedComments)
+ * </pre>
+ */
 public class PRED_unresolved_comments_count_1 extends Predicate.P1 {
   public PRED_unresolved_comments_count_1(Term a1, Operation n) {
     arg1 = a1;
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
index 681d86c..89e367e 100644
--- a/java/gerrit/PRED_uploader_1.java
+++ b/java/gerrit/PRED_uploader_1.java
@@ -27,6 +27,16 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 
+/**
+ * Prolog predicate for the uploader of the current patch set of a change.
+ *
+ * <p>Checks that the term that is provided as input to this Prolog predicate is a user ID term that
+ * matches the account ID of the uploader of the current patch set.
+ *
+ * <pre>
+ *   'uploader'(user(-ID))
+ * </pre>
+ */
 public class PRED_uploader_1 extends Predicate.P1 {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/org/apache/commons/net/BUILD b/java/org/apache/commons/net/BUILD
index c322ecd..d83d8ec 100644
--- a/java/org/apache/commons/net/BUILD
+++ b/java/org/apache/commons/net/BUILD
@@ -6,7 +6,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/util/ssl",
-        "//lib/commons:codec",
+        "//lib:guava",
         "//lib/commons:net",
     ],
 )
diff --git a/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index 33dd609..85e4dbf 100644
--- a/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.util.ssl.BlindSSLSocketFactory;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
@@ -33,7 +34,6 @@
 import javax.net.ssl.SSLParameters;
 import javax.net.ssl.SSLSocket;
 import javax.net.ssl.SSLSocketFactory;
-import org.apache.commons.codec.binary.Base64;
 
 public class AuthSMTPClient extends SMTPClient {
   private String authTypes;
@@ -134,7 +134,7 @@
     }
 
     final String enc = getReplyStrings()[0].split(" ", 2)[1];
-    final byte[] nonce = Base64.decodeBase64(enc.getBytes(UTF_8));
+    final byte[] nonce = BaseEncoding.base64().decode(enc);
     final String sec;
     try {
       Mac mac = Mac.getInstance(macName);
@@ -187,6 +187,6 @@
   }
 
   private static String encodeBase64(byte[] data) {
-    return new String(Base64.encodeBase64(data), UTF_8);
+    return BaseEncoding.base64().encode(data);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index ba12a12..07e74d4 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -22,7 +22,6 @@
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
@@ -57,14 +56,15 @@
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountIndexedCounter;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -89,7 +89,6 @@
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -103,9 +102,7 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
-import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -117,13 +114,12 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.mail.Address;
+import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
@@ -135,13 +131,11 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
-import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
@@ -155,7 +149,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -221,6 +214,7 @@
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
   @Inject private PermissionBackend permissionBackend;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
 
   @Inject protected Emails emails;
 
@@ -1478,680 +1472,14 @@
   public void refsUsersSelfIsAdvertised() throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       assertThat(
-              permissionBackend
-                  .currentUser()
-                  .project(allUsers)
-                  .filter(ImmutableList.of(), allUsersRepo, RefFilterOptions.defaults())
-                  .keySet())
+              permissionBackend.currentUser().project(allUsers)
+                  .filter(ImmutableList.of(), allUsersRepo, RefFilterOptions.defaults()).stream()
+                  .map(Ref::getName))
           .containsExactly(RefNames.REFS_USERS_SELF);
     }
   }
 
   @Test
-  public void pushToUserBranch() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-      allUsersRepo.reset("userRef");
-      PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
-      push.to(RefNames.refsUsers(admin.id())).assertOkStatus();
-      accountIndexedCounter.assertReindexOf(admin);
-
-      push = pushFactory.create(admin.newIdent(), allUsersRepo);
-      push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
-      accountIndexedCounter.assertReindexOf(admin);
-    }
-  }
-
-  @Test
-  public void pushToUserBranchForReview() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String userRefName = RefNames.refsUsers(admin.id());
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRefName + ":userRef");
-      allUsersRepo.reset("userRef");
-      PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
-      PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      gApi.changes().id(r.getChangeId()).current().submit();
-      accountIndexedCounter.assertReindexOf(admin);
-
-      push = pushFactory.create(admin.newIdent(), allUsersRepo);
-      r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      gApi.changes().id(r.getChangeId()).current().submit();
-      accountIndexedCounter.assertReindexOf(admin);
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String userRef = RefNames.refsUsers(admin.id());
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(MagicBranch.NEW_CHANGE + userRef);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
-
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      gApi.changes().id(r.getChangeId()).current().submit();
-      accountIndexedCounter.assertReindexOf(admin);
-
-      AccountInfo info = gApi.accounts().self().get();
-      assertThat(info.email).isEqualTo(admin.email());
-      assertThat(info.name).isEqualTo(admin.fullName());
-      assertThat(info.status).isEqualTo("out-of-office");
-    }
-  }
-
-  @Test
-  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
-      throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-      String userRef = RefNames.refsUsers(foo.id());
-      accountIndexedCounter.clear();
-
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      String email = "some.email@example.com";
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  foo.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(MagicBranch.NEW_CHANGE + userRef);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
-
-      requestScopeOperations.setApiUser(foo.id());
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      gApi.changes().id(r.getChangeId()).current().submit();
-
-      accountIndexedCounter.assertReindexOf(foo);
-
-      AccountInfo info = gApi.accounts().self().get();
-      assertThat(info.email).isEqualTo(email);
-      assertThat(info.name).isEqualTo(foo.fullName());
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
-      throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String userRef = RefNames.refsUsers(admin.id());
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  "invalid config")
-              .to(MagicBranch.NEW_CHANGE + userRef);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
-
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      ResourceConflictException thrown =
-          assertThrows(
-              ResourceConflictException.class,
-              () -> gApi.changes().id(r.getChangeId()).current().submit());
-      assertThat(thrown)
-          .hasMessageThat()
-          .contains(
-              String.format(
-                  "invalid account configuration: commit '%s' has an invalid '%s' file for account"
-                      + " '%s': Invalid config file %s in commit %s",
-                  r.getCommit().name(),
-                  AccountProperties.ACCOUNT_CONFIG,
-                  admin.id(),
-                  AccountProperties.ACCOUNT_CONFIG,
-                  r.getCommit().name()));
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
-      throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String userRef = RefNames.refsUsers(admin.id());
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      String noEmail = "no.email";
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(MagicBranch.NEW_CHANGE + userRef);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
-
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      ResourceConflictException thrown =
-          assertThrows(
-              ResourceConflictException.class,
-              () -> gApi.changes().id(r.getChangeId()).current().submit());
-      assertThat(thrown)
-          .hasMessageThat()
-          .contains(
-              String.format(
-                  "invalid account configuration: invalid preferred email '%s' for account '%s'",
-                  noEmail, admin.id()));
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
-      throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String userRef = RefNames.refsUsers(admin.id());
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(MagicBranch.NEW_CHANGE + userRef);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
-
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      ResourceConflictException thrown =
-          assertThrows(
-              ResourceConflictException.class,
-              () -> gApi.changes().id(r.getChangeId()).current().submit());
-      assertThat(thrown)
-          .hasMessageThat()
-          .contains("invalid account configuration: cannot deactivate own account");
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      projectOperations
-          .allProjectsForUpdate()
-          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
-          .update();
-
-      TestAccount foo = accountCreator.create(name("foo"));
-      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
-      String userRef = RefNames.refsUsers(foo.id());
-      accountIndexedCounter.clear();
-
-      projectOperations
-          .project(allUsers)
-          .forUpdate()
-          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
-          .add(allowLabel("Code-Review").ref(userRef).group(adminGroupUuid()).range(-2, 2))
-          .add(allow(Permission.SUBMIT).ref(userRef).group(adminGroupUuid()))
-          .update();
-
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(MagicBranch.NEW_CHANGE + userRef);
-      r.assertOkStatus();
-      accountIndexedCounter.assertNoReindex();
-      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
-
-      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-      gApi.changes().id(r.getChangeId()).current().submit();
-      accountIndexedCounter.assertReindexOf(foo);
-
-      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
-    }
-  }
-
-  @Test
-  public void pushWatchConfigToUserBranch() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config wc = new Config();
-      wc.setString(
-          ProjectWatches.PROJECT,
-          project.get(),
-          ProjectWatches.KEY_NOTIFY,
-          ProjectWatches.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
-      PushOneCommit push =
-          pushFactory.create(
-              admin.newIdent(),
-              allUsersRepo,
-              "Add project watch",
-              ProjectWatches.WATCH_CONFIG,
-              wc.toText());
-      push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
-      accountIndexedCounter.assertReindexOf(admin);
-
-      String invalidNotifyValue = "]invalid[";
-      wc.setString(
-          ProjectWatches.PROJECT, project.get(), ProjectWatches.KEY_NOTIFY, invalidNotifyValue);
-      push =
-          pushFactory.create(
-              admin.newIdent(),
-              allUsersRepo,
-              "Add invalid project watch",
-              ProjectWatches.WATCH_CONFIG,
-              wc.toText());
-      PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
-      r.assertErrorStatus("invalid account configuration");
-      r.assertMessage(
-          String.format(
-              "%s: Invalid project watch of account %d for project %s: %s",
-              ProjectWatches.WATCH_CONFIG, admin.id().get(), project.get(), invalidNotifyValue));
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranch() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
-      requestScopeOperations.setApiUser(oooUser.id());
-
-      // Must clone as oooUser to ensure the push is allowed.
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, oooUser);
-      fetch(allUsersRepo, RefNames.refsUsers(oooUser.id()) + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
-
-      accountIndexedCounter.clear();
-      pushFactory
-          .create(
-              oooUser.newIdent(),
-              allUsersRepo,
-              "Update account config",
-              AccountProperties.ACCOUNT_CONFIG,
-              ac.toText())
-          .to(RefNames.refsUsers(oooUser.id()))
-          .assertOkStatus();
-
-      accountIndexedCounter.assertReindexOf(oooUser);
-
-      AccountInfo info = gApi.accounts().self().get();
-      assertThat(info.email).isEqualTo(oooUser.email());
-      assertThat(info.name).isEqualTo(oooUser.fullName());
-      assertThat(info.status).isEqualTo("out-of-office");
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  "invalid config")
-              .to(RefNames.REFS_USERS_SELF);
-      r.assertErrorStatus("invalid account configuration");
-      r.assertMessage(
-          String.format(
-              "commit '%s' has an invalid '%s' file for account '%s':"
-                  + " Invalid config file %s in commit %s",
-              r.getCommit().name(),
-              AccountProperties.ACCOUNT_CONFIG,
-              admin.id(),
-              AccountProperties.ACCOUNT_CONFIG,
-              r.getCommit().name()));
-      accountIndexedCounter.assertNoReindex();
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      String noEmail = "no.email";
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(RefNames.REFS_USERS_SELF);
-      r.assertErrorStatus("invalid account configuration");
-      r.assertMessage(
-          String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id()));
-      accountIndexedCounter.assertNoReindex();
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-      String userRef = RefNames.refsUsers(foo.id());
-
-      String noEmail = "no.email";
-      accountsUpdateProvider
-          .get()
-          .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(noEmail));
-      accountIndexedCounter.clear();
-
-      projectOperations
-          .project(allUsers)
-          .forUpdate()
-          .add(allow(Permission.PUSH).ref(userRef).group(REGISTERED_USERS))
-          .update();
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      String status = "in vacation";
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, status);
-
-      pushFactory
-          .create(
-              foo.newIdent(),
-              allUsersRepo,
-              "Update account config",
-              AccountProperties.ACCOUNT_CONFIG,
-              ac.toText())
-          .to(userRef)
-          .assertOkStatus();
-      accountIndexedCounter.assertReindexOf(foo);
-
-      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-      assertThat(info.email).isEqualTo(noEmail);
-      assertThat(info.name).isEqualTo(foo.fullName());
-      assertThat(info.status).isEqualTo(status);
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
-      String userRef = RefNames.refsUsers(foo.id());
-      accountIndexedCounter.clear();
-
-      projectOperations
-          .project(allUsers)
-          .forUpdate()
-          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
-          .update();
-
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      String email = "some.email@example.com";
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
-
-      pushFactory
-          .create(
-              foo.newIdent(),
-              allUsersRepo,
-              "Update account config",
-              AccountProperties.ACCOUNT_CONFIG,
-              ac.toText())
-          .to(userRef)
-          .assertOkStatus();
-      accountIndexedCounter.assertReindexOf(foo);
-
-      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-      assertThat(info.email).isEqualTo(email);
-      assertThat(info.name).isEqualTo(foo.fullName());
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-      PushOneCommit.Result r =
-          pushFactory
-              .create(
-                  admin.newIdent(),
-                  allUsersRepo,
-                  "Update account config",
-                  AccountProperties.ACCOUNT_CONFIG,
-                  ac.toText())
-              .to(RefNames.REFS_USERS_SELF);
-      r.assertErrorStatus("invalid account configuration");
-      r.assertMessage("cannot deactivate own account");
-      accountIndexedCounter.assertNoReindex();
-    }
-  }
-
-  @Test
-  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      projectOperations
-          .allProjectsForUpdate()
-          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
-          .update();
-
-      TestAccount foo = accountCreator.create(name("foo"));
-      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
-      String userRef = RefNames.refsUsers(foo.id());
-      accountIndexedCounter.clear();
-
-      projectOperations
-          .project(allUsers)
-          .forUpdate()
-          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
-          .update();
-
-      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-      fetch(allUsersRepo, userRef + ":userRef");
-      allUsersRepo.reset("userRef");
-
-      Config ac = getAccountConfig(allUsersRepo);
-      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
-
-      pushFactory
-          .create(
-              admin.newIdent(),
-              allUsersRepo,
-              "Update account config",
-              AccountProperties.ACCOUNT_CONFIG,
-              ac.toText())
-          .to(userRef)
-          .assertOkStatus();
-      accountIndexedCounter.assertReindexOf(foo);
-
-      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
-    }
-  }
-
-  @Test
-  public void cannotCreateUserBranch() throws Exception {
-    projectOperations
-        .project(allUsers)
-        .forUpdate()
-        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
-        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
-        .update();
-
-    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
-    r.assertErrorStatus();
-    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
-    }
-  }
-
-  @Test
-  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
-    projectOperations
-        .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
-        .update();
-    projectOperations
-        .project(allUsers)
-        .forUpdate()
-        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
-        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
-        .update();
-
-    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef).assertOkStatus();
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNotNull();
-    }
-  }
-
-  @Test
-  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
-      throws Exception {
-    projectOperations
-        .allProjectsForUpdate()
-        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
-        .update();
-    projectOperations
-        .project(allUsers)
-        .forUpdate()
-        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
-        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
-        .update();
-
-    String userRef = RefNames.REFS_USERS + "foo";
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
-    r.assertErrorStatus();
-    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
-
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.exactRef(userRef)).isNull();
-    }
-  }
-
-  @Test
   public void createDefaultUserBranch() throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
@@ -2763,7 +2091,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
@@ -2817,7 +2147,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r ->
                     r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
                         .withBlockStrategy(noSleepBlockStrategy)),
@@ -2875,7 +2207,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
@@ -2948,7 +2282,9 @@
                 cfg,
                 retryMetrics,
                 null,
-                new PluginSetContext<>(DynamicSet.emptySet(), PluginMetrics.DISABLED_INSTANCE),
+                null,
+                null,
+                exceptionHooks,
                 r -> r.withBlockStrategy(noSleepBlockStrategy)),
             extIdNotesFactory,
             ident,
@@ -3010,7 +2346,7 @@
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
     Account.Id accountId = Account.id(accountInfo._accountId);
-    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
 
     // Manually updating the user ref makes the index document stale.
     String userRef = RefNames.refsUsers(accountId);
@@ -3078,11 +2414,11 @@
     // has to happen directly on the accounts cache because AccountCacheImpl triggers a reindex for
     // the account.
     accountsCache.invalidate(accountId);
-    assertThat(stalenessChecker.isStale(accountId)).isTrue();
+    assertThat(stalenessChecker.check(accountId).isStale()).isTrue();
 
     // Reindex fixes staleness
     accountIndexer.index(accountId);
-    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+    assertThat(stalenessChecker.check(accountId).isStale()).isFalse();
   }
 
   @Test
@@ -3495,62 +2831,6 @@
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.id());
   }
 
-  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
-    Config ac = new Config();
-    try (TreeWalk tw =
-        TreeWalk.forPath(
-            allUsersRepo.getRepository(),
-            AccountProperties.ACCOUNT_CONFIG,
-            getHead(allUsersRepo.getRepository(), "HEAD").getTree())) {
-      assertThat(tw).isNotNull();
-      ac.fromText(
-          new String(
-              allUsersRepo
-                  .getRevWalk()
-                  .getObjectReader()
-                  .open(tw.getObjectId(0), OBJ_BLOB)
-                  .getBytes(),
-              UTF_8));
-    }
-    return ac;
-  }
-
-  /** Checks if an account is indexed the correct number of times. */
-  private static class AccountIndexedCounter implements AccountIndexedListener {
-    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
-
-    @Override
-    public void onAccountIndexed(int id) {
-      countsByAccount.incrementAndGet(id);
-    }
-
-    void clear() {
-      countsByAccount.clear();
-    }
-
-    void assertReindexOf(TestAccount testAccount) {
-      assertReindexOf(testAccount, 1);
-    }
-
-    void assertReindexOf(AccountInfo accountInfo) {
-      assertReindexOf(Account.id(accountInfo._accountId), 1);
-    }
-
-    void assertReindexOf(TestAccount testAccount, long expectedCount) {
-      assertThat(countsByAccount.asMap()).containsExactly(testAccount.id().get(), expectedCount);
-      clear();
-    }
-
-    void assertReindexOf(Account.Id accountId, long expectedCount) {
-      assertThat(countsByAccount.asMap()).containsEntry(accountId.get(), expectedCount);
-      countsByAccount.remove(accountId.get());
-    }
-
-    void assertNoReindex() {
-      assertThat(countsByAccount.asMap()).isEmpty();
-    }
-  }
-
   private static class RefUpdateCounter implements GitReferenceUpdatedListener {
     private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 25617d4..a09284e 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -23,7 +23,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 5550d98..11ca391 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -21,9 +21,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
@@ -178,6 +178,18 @@
   }
 
   @Test
+  public void listAgreementPermission() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+    requestScopeOperations.setApiUser(admin.id());
+    // Allowed.
+    gApi.accounts().id(user.id().get()).listAgreements();
+    requestScopeOperations.setApiUser(user.id());
+
+    // Not allowed.
+    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.id().get()).listAgreements());
+  }
+
+  @Test
   public void signAgreementAsOtherUser() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
     assertThat(gApi.accounts().self().get().name).isNotEqualTo("admin");
@@ -239,6 +251,28 @@
   }
 
   @Test
+  public void revertSubmissionWithoutCLA() throws Exception {
+    assume().that(isContributorAgreementsEnabled()).isTrue();
+
+    // Create a change succeeds when agreement is not required
+    setUseContributorAgreements(InheritableBoolean.FALSE);
+    ChangeInfo change = gApi.changes().create(newChangeInput()).get();
+
+    // Approve and submit it
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(change.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(change.changeId).current().submit(new SubmitInput());
+
+    // Revert Submission is not allowed when CLA is required but not signed
+    requestScopeOperations.setApiUser(user.id());
+    setUseContributorAgreements(InheritableBoolean.TRUE);
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(change.changeId).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains("Contributor Agreement");
+  }
+
+  @Test
   public void revertExcludedProjectChangeWithoutCLA() throws Exception {
     // Contributor agreements configured with excludeProjects = ExcludedProject
     // in AbstractDaemonTest.configureContributorAgreement(...)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 8aebc69..d04eebd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -21,20 +21,23 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
+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.data.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.AbandonUtil;
@@ -45,6 +48,7 @@
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 public class AbandonIT extends AbstractDaemonTest {
@@ -136,6 +140,95 @@
   }
 
   @Test
+  @UseClockStep
+  @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
+  @GerritConfig(name = "changeCleanup.abandonIfMergeable", value = "false")
+  @GerritConfig(
+      name = "change.mergeabilityComputationBehavior",
+      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
+  public void notAbandonedIfMergeableWhenMergeableOperatorIsEnabled() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    // create 2 changes
+    int id1 = createChange().getChange().getId().get();
+    int id2 = createChange().getChange().getId().get();
+
+    // create 2 changes that conflict with each other
+    testRepo.reset(initial);
+    int id3 = createChange("change 3", "file.txt", "content").getChange().getId().get();
+    testRepo.reset(initial);
+    int id4 = createChange("change 4", "file.txt", "other content").getChange().getId().get();
+
+    // make all 4 previously created changes older than 1 week
+    TestTimeUtil.incrementClock(7 * 24, HOURS);
+
+    // create 1 new change that will not be abandoned because it is not older than 1 week
+    testRepo.reset(initial);
+    ChangeData cd = createChange().getChange();
+    int id5 = cd.getId().get();
+
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3, id4, id5);
+    assertThat(query("is:abandoned")).isEmpty();
+
+    // submit one of the conflicting changes
+    gApi.changes().id(id3).current().review(ReviewInput.approve());
+    gApi.changes().id(id3).current().submit();
+    assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
+    assertThat(toChangeNumbers(query("-is:mergeable"))).containsExactly(id4);
+
+    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5, id2, id1);
+    assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4);
+  }
+
+  /**
+   * When indexing mergeable is disabled then the abandonIfMergeable option is ineffective and the
+   * auto abandon behaves as though it were set to its default value (true).
+   */
+  @Test
+  @UseClockStep
+  @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
+  @GerritConfig(name = "changeCleanup.abandonIfMergeable", value = "false")
+  @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
+  public void abandonedIfMergeableWhenMergeableOperatorIsDisabled() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    // create 2 changes
+    int id1 = createChange().getChange().getId().get();
+    int id2 = createChange().getChange().getId().get();
+
+    // create 2 changes that conflict with each other
+    testRepo.reset(initial);
+    int id3 = createChange("change 3", "file.txt", "content").getChange().getId().get();
+    testRepo.reset(initial);
+    int id4 = createChange("change 4", "file.txt", "other content").getChange().getId().get();
+
+    // make all 4 previously created changes older than 1 week
+    TestTimeUtil.incrementClock(7 * 24, HOURS);
+
+    // create 1 new change that will not be abandoned because it is not older than 1 week
+    testRepo.reset(initial);
+    ChangeData cd = createChange().getChange();
+    int id5 = cd.getId().get();
+
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3, id4, id5);
+    assertThat(query("is:abandoned")).isEmpty();
+
+    // submit one of the conflicting changes
+    gApi.changes().id(id3).current().review(ReviewInput.approve());
+    gApi.changes().id(id3).current().submit();
+    assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> query("-is:mergeable"));
+    assertThat(thrown).hasMessageThat().contains("operator is not supported");
+
+    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5);
+    assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4, id2, id1);
+  }
+
+  @Test
   public void changeCleanupConfigDefaultAbandonMessage() throws Exception {
     assertThat(cleanupConfig.getAbandonMessage())
         .startsWith(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index f176663..d5686d1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -74,13 +74,13 @@
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -249,6 +249,7 @@
     assertThat(c.owner.email).isNull();
     assertThat(c.owner.username).isNull();
     assertThat(c.owner.avatars).isNull();
+    assertThat(c.submissionId).isNull();
   }
 
   @Test
@@ -294,19 +295,7 @@
   }
 
   @Test
-  public void skipMergeable() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    ChangeInfo c =
-        gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_MERGEABLE));
-    assertThat(c.mergeable).isNull();
-
-    c = gApi.changes().id(triplet).get();
-    assertThat(c.mergeable).isTrue();
-  }
-
-  @Test
-  @GerritConfig(name = "change.api.excludeMergeableInChangeInfo", value = "true")
+  @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void excludeMergeableInChangeInfo() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
@@ -314,6 +303,15 @@
   }
 
   @Test
+  public void getSubmissionId() throws Exception {
+    PushOneCommit.Result changeResult = createChange();
+    String changeId = changeResult.getChangeId();
+
+    merge(changeResult);
+    assertThat(gApi.changes().id(changeId).get().submissionId).isNotNull();
+  }
+
+  @Test
   public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result rwip = createChange();
     String changeId = rwip.getChangeId();
@@ -2194,7 +2192,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 2));
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
@@ -2202,7 +2200,26 @@
     m = gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) -1));
+    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) -1));
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void listVotesEvenWhenAccountsAreNotVisible() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // check finding by address works
+    Map<String, Short> m = gApi.changes().id(r.getChangeId()).reviewer(admin.email()).votes();
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+
+    // check finding by id works
+    m = gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
+    assertThat(m).hasSize(1);
+    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
   }
 
   @Test
@@ -3380,6 +3397,7 @@
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 2);
@@ -3540,6 +3558,7 @@
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 0, 1, 2);
@@ -3555,11 +3574,34 @@
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
     assertPermitted(change, "Code-Review", 0, 1, 2);
   }
 
   @Test
+  public void checkSubmissionIdForAutoClosedChange() throws Exception {
+    PushOneCommit.Result first = createChange();
+    PushOneCommit.Result second = createChange();
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+
+    PushOneCommit.Result result = push.to("refs/heads/master");
+    result.assertOkStatus();
+
+    ChangeInfo firstChange = gApi.changes().id(first.getChangeId()).get();
+    assertThat(firstChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(firstChange.submissionId).isNotNull();
+
+    ChangeInfo secondChange = gApi.changes().id(second.getChangeId()).get();
+    assertThat(secondChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(secondChange.submissionId).isNotNull();
+
+    assertThat(secondChange.submissionId).isEqualTo(firstChange.submissionId);
+    assertThat(gApi.changes().id(second.getChangeId()).submittedTogether()).hasSize(2);
+  }
+
+  @Test
   public void maxPermittedValueAllowed() throws Exception {
     final int minPermittedValue = -2;
     final int maxPermittedValue = +2;
@@ -4371,7 +4413,6 @@
             ListChangesOption.MESSAGES,
             ListChangesOption.SUBMITTABLE,
             ListChangesOption.WEB_LINKS,
-            ListChangesOption.SKIP_MERGEABLE,
             ListChangesOption.SKIP_DIFFSTAT);
 
     PushOneCommit.Result change = createChange();
@@ -4383,6 +4424,22 @@
     }
   }
 
+  @Test
+  @GerritConfig(
+      name = "change.mergeabilityComputationBehavior",
+      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
+  public void changeQueryReturnsMergeableWhenGerritIndexMergeable() throws Exception {
+    String changeId = createChange().getChangeId();
+    assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
+  public void changeQueryDoesNotReturnMergeableWhenGerritDoesNotIndexMergeable() throws Exception {
+    String changeId = createChange().getChangeId();
+    assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isNull();
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index b8a4871..a0a88f8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
index 42d62bd..eee25b8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 7156c8d..707076c 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.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -24,19 +28,24 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.CommentsRejectedException;
@@ -44,9 +53,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatcher;
 import org.mockito.Captor;
 
 /** Tests for comment validation in {@link PostReview}. */
@@ -55,8 +67,43 @@
   @Inject private TestCommentHelper testCommentHelper;
 
   private static final String COMMENT_TEXT = "The comment text";
+  private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
+      CommentForValidation.create(
+          CommentForValidation.CommentSource.HUMAN,
+          CommentForValidation.CommentType.FILE_COMMENT,
+          COMMENT_TEXT,
+          COMMENT_TEXT.length());
+  private static final CommentForValidation INLINE_COMMENT_FOR_VALIDATION =
+      CommentForValidation.create(
+          CommentForValidation.CommentSource.HUMAN,
+          CommentForValidation.CommentType.INLINE_COMMENT,
+          COMMENT_TEXT,
+          COMMENT_TEXT.length());
+  private static final CommentForValidation CHANGE_MESSAGE_FOR_VALIDATION =
+      CommentForValidation.create(
+          CommentForValidation.CommentSource.HUMAN,
+          CommentForValidation.CommentType.CHANGE_MESSAGE,
+          COMMENT_TEXT,
+          COMMENT_TEXT.length());
 
-  @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> capture;
+  @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor;
+
+  private static class SingleCommentMatcher
+      implements ArgumentMatcher<ImmutableList<CommentForValidation>> {
+    final CommentForValidation left;
+
+    SingleCommentMatcher(CommentForValidation left) {
+      this.left = left;
+    }
+
+    @Override
+    public boolean matches(ImmutableList<CommentForValidation> rightList) {
+      CommentForValidation right = Iterables.getOnlyElement(rightList);
+      return left.getSource() == right.getSource()
+          && left.getType() == right.getType()
+          && left.getText().equals(right.getText());
+    }
+  }
 
   @Override
   public Module createModule() {
@@ -80,13 +127,10 @@
 
   @Test
   public void validateCommentsInInput_commentOK() throws Exception {
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(
-                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of());
-
     PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(
+            eq(contextFor(r)), argThat(new SingleCommentMatcher(FILE_COMMENT_FOR_VALIDATION))))
+        .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput();
     CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
@@ -101,13 +145,10 @@
 
   @Test
   public void validateCommentsInInput_commentRejected() throws Exception {
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT);
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
-
     PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(
+            eq(contextFor(r)), argThat(new SingleCommentMatcher(FILE_COMMENT_FOR_VALIDATION))))
+        .thenReturn(ImmutableList.of(FILE_COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
 
     ReviewInput input = new ReviewInput();
     CommentInput comment = newComment(r.getChange().currentFilePaths().get(0));
@@ -151,18 +192,15 @@
 
   @Test
   public void validateDrafts_draftOK() throws Exception {
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(
-                    CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of());
-
     PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(
+            eq(contextFor(r)), argThat(new SingleCommentMatcher((INLINE_COMMENT_FOR_VALIDATION)))))
+        .thenReturn(ImmutableList.of());
 
     DraftInput draft =
         testCommentHelper.newDraft(
             r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().getName()).createDraft(draft).get();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().getName()).createDraft(draft);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
 
     ReviewInput input = new ReviewInput();
@@ -174,14 +212,10 @@
 
   @Test
   public void validateDrafts_draftRejected() throws Exception {
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentType.INLINE_COMMENT, COMMENT_TEXT);
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(
-                    CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
     PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(
+            eq(contextFor(r)), argThat(new SingleCommentMatcher(INLINE_COMMENT_FOR_VALIDATION))))
+        .thenReturn(ImmutableList.of(INLINE_COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
 
     DraftInput draft =
         testCommentHelper.newDraft(
@@ -218,29 +252,42 @@
     testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draftFile);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
 
-    when(mockCommentValidator.validateComments(capture.capture())).thenReturn(ImmutableList.of());
+    when(mockCommentValidator.validateComments(any(), captor.capture()))
+        .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput();
     input.drafts = DraftHandling.PUBLISH;
     gApi.changes().id(r.getChangeId()).current().review(input);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(2);
 
-    assertThat(capture.getAllValues()).hasSize(1);
-    assertThat(capture.getValue())
+    assertThat(captor.getAllValues()).hasSize(1);
+    assertThat(captor.getValue())
+        .comparingElementsUsing(
+            Correspondence.<CommentForValidation, CommentForValidation>from(
+                (a, b) ->
+                    a.getSource() == b.getSource()
+                        && a.getType() == b.getType()
+                        && a.getText().equals(b.getText()),
+                "matches (ignoring size approximation)"))
         .containsExactly(
             CommentForValidation.create(
-                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
+                CommentForValidation.CommentSource.HUMAN,
+                CommentForValidation.CommentType.INLINE_COMMENT,
+                draftInline.message,
+                draftInline.message.length()),
             CommentForValidation.create(
-                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+                CommentForValidation.CommentSource.HUMAN,
+                CommentForValidation.CommentType.FILE_COMMENT,
+                draftFile.message,
+                draftFile.message.length()));
   }
 
   @Test
   public void validateCommentsInChangeMessage_messageOK() throws Exception {
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of());
     PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(
+            contextFor(r), ImmutableList.of(CHANGE_MESSAGE_FOR_VALIDATION)))
+        .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     int numMessages = gApi.changes().id(r.getChangeId()).get().messages.size();
@@ -253,13 +300,10 @@
 
   @Test
   public void validateCommentsInChangeMessage_messageRejected() throws Exception {
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
     PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(
+            contextFor(r), ImmutableList.of(CHANGE_MESSAGE_FOR_VALIDATION)))
+        .thenReturn(ImmutableList.of(CHANGE_MESSAGE_FOR_VALIDATION.failValidation("Oh no!")));
 
     ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
     assertThat(gApi.changes().id(r.getChangeId()).get().messages)
@@ -284,7 +328,56 @@
     assertThat(message.message).doesNotContain(COMMENT_TEXT);
   }
 
+  @Test
+  @GerritConfig(name = "change.maxComments", value = "4")
+  public void restrictNumberOfComments() throws Exception {
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+
+    PushOneCommit.Result r = createChange();
+    String filePath = r.getChange().currentFilePaths().get(0);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "foo";
+    commentInput.path = filePath;
+    RobotCommentInput robotCommentInput =
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields(filePath);
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(filePath, ImmutableList.of(commentInput));
+    reviewInput.robotComments = ImmutableMap.of(filePath, ImmutableList.of(robotCommentInput));
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // reviewInput still has both a user and a robot comment (and deduplication is false). We also
+    // create a draft so that in total there would be 5 comments. The limit is set to 4, so this
+    // verifies that all three channels are considered.
+    DraftInput draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, "a draft");
+    testCommentHelper.addDraft(r.getChangeId(), r.getPatchSetId().getId(), draftInline);
+    reviewInput.drafts = DraftHandling.PUBLISH;
+    reviewInput.omitDuplicateComments = false;
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(reviewInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .contains("Exceeding maximum number of comments: 2 (existing) + 3 (new) > 4");
+
+    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+    assertThat(getRobotComments(r.getChangeId())).hasSize(1);
+  }
+
+  private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
+    return gApi.changes().id(changeId).robotComments().values().stream()
+        .flatMap(Collection::stream)
+        .collect(toList());
+  }
+
   private static CommentInput newComment(String path) {
     return TestCommentHelper.populate(new CommentInput(), path, PostReviewIT.COMMENT_TEXT);
   }
+
+  private static CommentValidationContext contextFor(PushOneCommit.Result result) {
+    return CommentValidationContext.create(
+        result.getChange().getId().get(), result.getChange().project().get());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
index 92f914b..b5d673c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangeIT.java
@@ -15,21 +15,32 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 @NoHttpd
 public class QueryChangeIT extends AbstractDaemonTest {
-
+  @Inject private ProjectOperations projectOperations;
   @Inject private Provider<QueryChanges> queryChangesProvider;
 
   @Test
@@ -97,6 +108,93 @@
     assertThat(result2.get(1).get(0)._moreChanges).isTrue();
   }
 
+  @Test
+  @SuppressWarnings("unchecked")
+  @GerritConfig(name = "operator-alias.change.numberaliastest", value = "change")
+  public void aliasQuery() throws Exception {
+    String cId1 = createChange().getChangeId();
+    String cId2 = createChange().getChangeId();
+    int numericId1 = gApi.changes().id(cId1).get()._number;
+    int numericId2 = gApi.changes().id(cId2).get()._number;
+
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("numberaliastest:12345");
+    queryChanges.addQuery("numberaliastest:" + numericId1);
+    queryChanges.addQuery("numberaliastest:" + numericId2);
+
+    List<List<ChangeInfo>> result =
+        (List<List<ChangeInfo>>) queryChanges.apply(TopLevelResource.INSTANCE).value();
+    assertThat(result).hasSize(3);
+    assertThat(result.get(0)).hasSize(0);
+    assertThat(result.get(1)).hasSize(1);
+    assertThat(result.get(2)).hasSize(1);
+
+    assertThat(result.get(1).get(0)._number).isEqualTo(numericId1);
+    assertThat(result.get(2).get(0)._number).isEqualTo(numericId2);
+  }
+
+  @Test
+  @UseClockStep
+  @SuppressWarnings("unchecked")
+  public void withPagedResults() throws Exception {
+    // Create 4 visible changes.
+    createChange(testRepo).getChange().getId().get();
+    createChange(testRepo).getChange().getId().get();
+    int changeId3 = createChange(testRepo).getChange().getId().get();
+    int changeId4 = createChange(testRepo).getChange().getId().get();
+
+    // Create hidden project.
+    Project.NameKey hiddenProject = projectOperations.newProject().create();
+    projectOperations
+        .project(hiddenProject)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+
+    // Create 2 hidden changes.
+    createChange(hiddenRepo);
+    createChange(hiddenRepo);
+
+    // Create a change query that matches all changes (visible and hidden changes).
+    // The index returns the changes ordered by last updated timestamp:
+    // hiddenChange2, hiddenChange1, change4, change3, change2, change1
+    QueryChanges queryChanges = queryChangesProvider.get();
+    queryChanges.addQuery("branch:master");
+
+    // Set a limit on the query so that we need to paginate over the results from the index.
+    queryChanges.setLimit(2);
+
+    // Execute the query and verify the results.
+    // Since the limit is set to 2, at most 2 changes are returned to user, but the index query is
+    // executed with limit 3 (+1 so that we can populate the _more_changes field on the last
+    // result).
+    // This means the index query with limit 3 returns these changes:
+    // hiddenChange2, hiddenChange1, change4
+    // The 2 hidden changes are filtered out because they are not visible to the caller.
+    // This means we have only one matching result (change4) but the limit (3) is not exhausted
+    // yet. Hence the next page is loaded from the index (startIndex is 3 to skip the results
+    // that we already processed, limit is again 3). The results for the next page are:
+    // change3, change2, change1
+    // change2 and change1 are dropped because they are over the limit.
+    List<ChangeInfo> result =
+        (List<ChangeInfo>) queryChanges.apply(TopLevelResource.INSTANCE).value();
+    assertThat(result.stream().map(i -> i._number).collect(toList()))
+        .containsExactly(changeId3, changeId4);
+  }
+
+  @Test
+  public void usingOutOfRangeLabelValuesDoesNotCauseError() throws Exception {
+    for (String operator : ImmutableList.of("=", ">", ">=", "<", "<=")) {
+      QueryChanges queryChanges = queryChangesProvider.get();
+      queryChanges.addQuery("label:Code-Review" + operator + "10");
+      queryChanges.addQuery("label:Code-Review" + operator + "-10");
+      queryChanges.addQuery("Code-Review" + operator + "10");
+      queryChanges.addQuery("Code-Review" + operator + "-10");
+      assertThat(queryChanges.apply(TopLevelResource.INSTANCE).statusCode()).isEqualTo(SC_OK);
+    }
+  }
+
   private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
     for (ChangeInfo info : results) {
       assertThat(info._moreChanges).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 0607a3c..fdf3758 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -23,10 +23,14 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+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.data.Permission;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -36,6 +40,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RevertSubmissionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -45,10 +51,13 @@
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
 public class RevertIT extends AbstractDaemonTest {
@@ -275,6 +284,27 @@
   }
 
   @Test
+  public void revertChangeWithLongSubject() throws Exception {
+    String changeTitle =
+        "This change has a very long title and therefore it will be cut to 50 characters when the"
+            + " revert change will revert this change";
+    String result = createChange(changeTitle, "a.txt", "message").getChangeId();
+    gApi.changes().id(result).current().review(ReviewInput.approve());
+    gApi.changes().id(result).current().submit();
+    RevertInput revertInput = new RevertInput();
+    ChangeInfo revertChange = gApi.changes().id(result).revert(revertInput).get();
+    assertThat(revertChange.subject)
+        .isEqualTo(String.format("Revert \"%s...\"", changeTitle.substring(0, 59)));
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"%s...\"\n\nThis reverts commit %s.\n\nChange-Id: %s\n",
+                changeTitle.substring(0, 59),
+                gApi.changes().id(result).get().currentRevision,
+                revertChange.changeId));
+  }
+
+  @Test
   public void revertNotifications() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
@@ -361,17 +391,20 @@
 
   @Test
   public void cantCreateRevertWithoutProjectWritePermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    PushOneCommit.Result result = createChange();
+    gApi.changes()
+        .id(result.getChangeId())
+        .revision(result.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
     projectCache.checkedGet(project).getProject().setState(ProjectState.READ_ONLY);
 
+    String expected = "project state " + ProjectState.READ_ONLY + " does not permit write";
     ResourceConflictException thrown =
         assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(r.getChangeId()).revert());
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("project state " + ProjectState.READ_ONLY + " does not permit write");
+            ResourceConflictException.class,
+            () -> gApi.changes().id(result.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains(expected);
   }
 
   @Test
@@ -399,17 +432,740 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).revert());
+    assertThat(thrown).hasMessageThat().contains("Not found: " + r.getChangeId());
+  }
 
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantCreateRevertSubmissionWithoutProjectWritePermission() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    String change1 =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic).getChangeId();
+    String change2 =
+        createChange(secondRepo, "master", "second change", "b.txt", "message", topic)
+            .getChangeId();
+    gApi.changes().id(change1).current().review(ReviewInput.approve());
+    gApi.changes().id(change2).current().review(ReviewInput.approve());
+    gApi.changes().id(change1).current().submit();
+
+    // revoke write permissions for the first repository.
+    projectCache.checkedGet(project).getProject().setState(ProjectState.READ_ONLY);
+
+    String expected = "project state " + ProjectState.READ_ONLY + " does not permit write";
+
+    // assert that if first repository has no write permissions, it will fail.
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(change1).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains(expected);
+
+    // assert that if the first repository has no write permissions and a change from another
+    // repository is trying to revert the submission, it will fail.
+    thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(change2).revertSubmission());
+    assertThat(thrown).hasMessageThat().contains(expected);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantCreateRevertSubmissionWithoutCreateChangePermission() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    String change1 =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic).getChangeId();
+    String change2 =
+        createChange(secondRepo, "master", "second change", "b.txt", "message", topic)
+            .getChangeId();
+    gApi.changes().id(change1).current().review(ReviewInput.approve());
+    gApi.changes().id(change2).current().review(ReviewInput.approve());
+    gApi.changes().id(change1).current().submit();
+
+    // revoke create change permissions for the first repository.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+        .update();
+
+    // assert that if first repository has no write create change, it will fail.
+    PermissionDeniedException thrown =
+        assertThrows(
+            PermissionDeniedException.class, () -> gApi.changes().id(change1).revertSubmission());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("not permitted: create change on refs/heads/master");
+
+    // assert that if the first repository has no create change permissions and a change from
+    // another repository is trying to revert the submission, it will fail.
+    thrown =
+        assertThrows(
+            PermissionDeniedException.class, () -> gApi.changes().id(change2).revertSubmission());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("not permitted: create change on refs/heads/master");
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantCreateRevertSubmissionWithoutReadPermission() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    String change1 =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic).getChangeId();
+    String change2 =
+        createChange(secondRepo, "master", "second change", "b.txt", "message", topic)
+            .getChangeId();
+    gApi.changes().id(change1).current().review(ReviewInput.approve());
+    gApi.changes().id(change2).current().review(ReviewInput.approve());
+    gApi.changes().id(change1).current().submit();
+
+    // revoke read permissions for the first repository.
     projectOperations
         .project(project)
         .forUpdate()
         .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
         .update();
 
-    ResourceNotFoundException thrown =
+    // assert that if first repository has no read permissions, it will fail.
+    ResourceNotFoundException resourceNotFoundException =
         assertThrows(
-            ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).revert());
-    assertThat(thrown).hasMessageThat().contains("Not found: " + r.getChangeId());
+            ResourceNotFoundException.class, () -> gApi.changes().id(change1).revertSubmission());
+    assertThat(resourceNotFoundException).hasMessageThat().isEqualTo("Not found: " + change1);
+
+    // assert that if the first repository has no READ permissions and a change from another
+    // repository is trying to revert the submission, it will fail.
+    AuthException authException =
+        assertThrows(AuthException.class, () -> gApi.changes().id(change2).revertSubmission());
+    assertThat(authException).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void revertSubmissionPreservesReviewersAndCcs() throws Exception {
+    String change = createChange("first change", "a.txt", "message").getChangeId();
+
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email());
+    in.reviewer(accountCreator.user2().email(), ReviewerState.CC, true);
+    // Add user as reviewer that will create the revert
+    in.reviewer(accountCreator.admin2().email());
+
+    gApi.changes().id(change).current().review(in);
+    gApi.changes().id(change).current().submit();
+
+    // expect both the original reviewers and CCs to be preserved
+    // original owner should be added as reviewer, user requesting the revert (new owner) removed
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        getChangeApis(gApi.changes().id(change).revertSubmission()).get(0).get().reviewers;
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(result).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs).containsExactly(accountCreator.user2().id().get());
+    assertThat(reviewers).containsExactly(user.id().get(), admin.id().get());
+  }
+
+  @Test
+  public void revertSubmissionNotifications() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.ALL;
+
+    RevertSubmissionInfo revertChanges =
+        gApi.changes().id(secondResult).revertSubmission(revertInput);
+
+    List<Message> messages = sender.getMessages();
+
+    assertThat(messages).hasSize(4);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(0).changeId, "newchange"))
+        .hasSize(1);
+    assertThat(sender.getMessages(firstResult, "revert")).hasSize(1);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(1).changeId, "newchange"))
+        .hasSize(1);
+    assertThat(sender.getMessages(secondResult, "revert")).hasSize(1);
+  }
+
+  @Test
+  public void revertSubmissionIdenticalTreeIsAllowed() throws Exception {
+    String unrelatedChange = createChange("change1", "a.txt", "message").getChangeId();
+    approve(unrelatedChange);
+    gApi.changes().id(unrelatedChange).current().submit();
+
+    String emptyChange = createChange("change1", "a.txt", "message").getChangeId();
+    approve(emptyChange);
+    String changeToBeReverted = createChange("change2", "b.txt", "message").getChangeId();
+    approve(changeToBeReverted);
+
+    gApi.changes().id(changeToBeReverted).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.ALL;
+
+    List<ChangeApi> revertChanges =
+        getChangeApis(gApi.changes().id(changeToBeReverted).revertSubmission(revertInput));
+    assertThat(revertChanges.size()).isEqualTo(2);
+  }
+
+  @Test
+  public void suppressRevertSubmissionNotifications() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+
+    sender.clear();
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionOfSingleChange() throws Exception {
+    PushOneCommit.Result result = createChange("Change", "a.txt", "message");
+    String resultId = result.getChangeId();
+    approve(resultId);
+    gApi.changes().id(resultId).current().submit();
+    List<ChangeApi> revertChanges = getChangeApis(gApi.changes().id(resultId).revertSubmission());
+
+    String sha1Commit = result.getCommit().getName();
+
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1Commit);
+
+    assertThat(revertChanges.get(0).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+
+    assertThat(revertChanges.get(0).get().revertOf)
+        .isEqualTo(result.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).get().topic)
+        .startsWith("revert-" + result.getChange().change().getSubmissionId() + "-");
+  }
+
+  @Test
+  public void revertSubmissionWithSetTopic() throws Exception {
+    String result = createChange().getChangeId();
+    gApi.changes().id(result).current().review(ReviewInput.approve());
+    gApi.changes().id(result).topic("topic");
+    gApi.changes().id(result).current().submit();
+    RevertInput revertInput = new RevertInput();
+    revertInput.topic = "reverted-not-default";
+    assertThat(gApi.changes().id(result).revertSubmission(revertInput).revertChanges.get(0).topic)
+        .isEqualTo(revertInput.topic);
+  }
+
+  @Test
+  public void revertSubmissionWithSetMessage() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    String secondResult = createChange("second change", "b.txt", "message").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    gApi.changes().id(secondResult).current().submit();
+    RevertInput revertInput = new RevertInput();
+    String commitMessage = "Message from input";
+    revertInput.message = commitMessage;
+    List<ChangeInfo> revertChanges =
+        gApi.changes().id(firstResult).revertSubmission(revertInput).revertChanges;
+    assertThat(revertChanges.get(0).subject).isEqualTo("Revert \"first change\"");
+    assertThat(gApi.changes().id(revertChanges.get(0).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"first change\"\n\n%s\n\nChange-Id: %s\n",
+                commitMessage, revertChanges.get(0).changeId));
+    assertThat(revertChanges.get(1).subject).isEqualTo("Revert \"second change\"");
+    assertThat(gApi.changes().id(revertChanges.get(1).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"second change\"\n\n%s\n\nChange-Id: %s\n",
+                commitMessage, revertChanges.get(1).changeId));
+  }
+
+  @Test
+  public void revertSubmissionWithoutMessage() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    String secondResult = createChange("second change", "b.txt", "message").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    gApi.changes().id(secondResult).current().submit();
+    RevertInput revertInput = new RevertInput();
+    List<ChangeInfo> revertChanges =
+        gApi.changes().id(firstResult).revertSubmission(revertInput).revertChanges;
+    assertThat(revertChanges.get(0).subject).isEqualTo("Revert \"first change\"");
+    assertThat(gApi.changes().id(revertChanges.get(0).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"first change\"\n\nThis reverts commit %s.\n\nChange-Id: %s\n",
+                gApi.changes().id(firstResult).get().currentRevision,
+                revertChanges.get(0).changeId));
+    assertThat(revertChanges.get(1).subject).isEqualTo("Revert \"second change\"");
+    assertThat(gApi.changes().id(revertChanges.get(1).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"second change\"\n\nThis reverts commit %s.\n\nChange-Id: %s\n",
+                gApi.changes().id(secondResult).get().currentRevision,
+                revertChanges.get(1).changeId));
+  }
+
+  @Test
+  public void revertSubmissionRevertsChangeWithLongSubject() throws Exception {
+    String changeTitle =
+        "This change has a very long title and therefore it will be cut to 56 characters when the"
+            + " revert change will revert this change";
+    String result = createChange(changeTitle, "a.txt", "message").getChangeId();
+    gApi.changes().id(result).current().review(ReviewInput.approve());
+    gApi.changes().id(result).current().submit();
+    RevertInput revertInput = new RevertInput();
+    ChangeInfo revertChange =
+        gApi.changes().id(result).revertSubmission(revertInput).revertChanges.get(0);
+    assertThat(revertChange.subject)
+        .isEqualTo(String.format("Revert \"%s...\"", changeTitle.substring(0, 56)));
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"%s...\"\n\nThis reverts commit %s.\n\nChange-Id: %s\n",
+                changeTitle.substring(0, 56),
+                gApi.changes().id(result).get().currentRevision,
+                revertChange.changeId));
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void revertSubmissionDifferentRepositoriesWithDependantChange() throws Exception {
+    projectOperations.newProject().name("secondProject").create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    List<PushOneCommit.Result> resultCommits = new ArrayList<>();
+    String topic = "topic";
+    resultCommits.add(
+        createChange(secondRepo, "master", "first change", "a.txt", "message", topic));
+    resultCommits.add(
+        createChange(secondRepo, "master", "second change", "b.txt", "Other message", topic));
+    resultCommits.add(
+        createChange(testRepo, "master", "main repo change", "a.txt", "message", topic));
+    for (PushOneCommit.Result result : resultCommits) {
+      approve(result.getChangeId());
+    }
+    // submit all changes
+    gApi.changes().id(resultCommits.get(1).getChangeId()).current().submit();
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(resultCommits.get(1).getChangeId()).revertSubmission();
+    assertThat(
+            revertSubmissionInfo.revertChanges.stream()
+                .map(change -> change.created)
+                .distinct()
+                .count())
+        .isEqualTo(1);
+
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+
+    assertThat(revertChanges).hasSize(3);
+
+    String sha1RevertOfTheSecondChange = revertChanges.get(1).current().commit(false).commit;
+    String sha1SecondChange = resultCommits.get(1).getCommit().getName();
+    String sha1ThirdChange = resultCommits.get(2).getCommit().getName();
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1RevertOfTheSecondChange);
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondChange);
+    assertThat(revertChanges.get(2).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1ThirdChange);
+
+    assertThat(revertChanges.get(0).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(2).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    // has size 3 because of the same topic, and submitWholeTopic is true.
+    assertThat(gApi.changes().id(revertChanges.get(0).get()._number).submittedTogether())
+        .hasSize(3);
+
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // 4. Created a revert of this change as %s
+
+    for (int i = 0; i < resultCommits.size(); i++) {
+      assertThat(revertChanges.get(i).get().revertOf)
+          .isEqualTo(resultCommits.get(i).getChange().change().getChangeId());
+      List<ChangeMessageInfo> sourceMessages =
+          new ArrayList<>(gApi.changes().id(resultCommits.get(i).getChangeId()).get().messages);
+      assertThat(sourceMessages).hasSize(4);
+      String expectedMessage =
+          String.format(
+              "Created a revert of this change as %s", revertChanges.get(i).get().changeId);
+      assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+      // Expected message on the created change: "Uploaded patch set 1."
+      List<ChangeMessageInfo> messages =
+          revertChanges.get(i).get().messages.stream().collect(toList());
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).message).isEqualTo("Uploaded patch set 1.");
+      assertThat(revertChanges.get(i).get().revertOf)
+          .isEqualTo(gApi.changes().id(resultCommits.get(i).getChangeId()).get()._number);
+      assertThat(revertChanges.get(i).get().topic)
+          .startsWith("revert-" + resultCommits.get(0).getChange().change().getSubmissionId());
+    }
+
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
+  }
+
+  @Test
+  public void cantRevertSubmissionWithAnOpenChange() throws Exception {
+    String result = createChange("change", "a.txt", "message").getChangeId();
+    approve(result);
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(result).revertSubmission());
+    assertThat(thrown).hasMessageThat().isEqualTo("change is new.");
+  }
+
+  @Test
+  public void revertSubmissionWithDependantChange() throws Exception {
+    PushOneCommit.Result firstResult = createChange("first change", "a.txt", "message");
+    PushOneCommit.Result secondResult = createChange("second change", "b.txt", "other");
+    approve(secondResult.getChangeId());
+    approve(firstResult.getChangeId());
+    gApi.changes().id(secondResult.getChangeId()).current().submit();
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(firstResult.getChangeId()).revertSubmission();
+    assertThat(
+            revertSubmissionInfo.revertChanges.stream()
+                .map(change -> change.created)
+                .distinct()
+                .count())
+        .isEqualTo(1);
+
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    Collections.reverse(revertChanges);
+    String sha1SecondChange = secondResult.getCommit().getName();
+    String sha1FirstRevert = revertChanges.get(0).current().commit(false).commit;
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondChange);
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstRevert);
+    assertThat(revertChanges.get(0).get().revertOf)
+        .isEqualTo(secondResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(1).get().revertOf)
+        .isEqualTo(firstResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+
+    assertThat(revertChanges).hasSize(2);
+    assertThat(gApi.changes().id(revertChanges.get(0).id()).current().related().changes).hasSize(2);
+  }
+
+  @Test
+  public void revertSubmissionWithDependantChangeWithoutRevertingLastOne() throws Exception {
+    PushOneCommit.Result firstResult = createChange("first change", "a.txt", "message");
+    PushOneCommit.Result secondResult = createChange("second change", "b.txt", "other");
+    approve(secondResult.getChangeId());
+    approve(firstResult.getChangeId());
+    gApi.changes().id(secondResult.getChangeId()).current().submit();
+    String unrelated = createChange("other change", "c.txt", "message other").getChangeId();
+    approve(unrelated);
+    gApi.changes().id(unrelated).current().submit();
+    List<ChangeApi> revertChanges =
+        getChangeApis(gApi.changes().id(firstResult.getChangeId()).revertSubmission());
+    Collections.reverse(revertChanges);
+    String sha1SecondChange = secondResult.getCommit().getName();
+    String sha1FirstRevert = revertChanges.get(0).current().commit(false).commit;
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondChange);
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstRevert);
+    assertThat(revertChanges.get(0).get().revertOf)
+        .isEqualTo(secondResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(1).get().revertOf)
+        .isEqualTo(firstResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+
+    assertThat(revertChanges).hasSize(2);
+    assertThat(gApi.changes().id(revertChanges.get(0).id()).current().related().changes).hasSize(2);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void revertSubmissionDifferentRepositories() throws Exception {
+    projectOperations.newProject().name("secondProject").create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    PushOneCommit.Result firstResult =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic);
+    PushOneCommit.Result secondResult =
+        createChange(secondRepo, "master", "second change", "b.txt", "other", topic);
+    approve(secondResult.getChangeId());
+    approve(firstResult.getChangeId());
+    // submit both changes
+    gApi.changes().id(secondResult.getChangeId()).current().submit();
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(secondResult.getChangeId()).revertSubmission();
+    assertThat(
+            revertSubmissionInfo.revertChanges.stream()
+                .map(change -> change.created)
+                .distinct()
+                .count())
+        .isEqualTo(1);
+
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    // has size 2 because of the same topic, and submitWholeTopic is true.
+    assertThat(gApi.changes().id(revertChanges.get(0).get()._number).submittedTogether())
+        .hasSize(2);
+    String sha1SecondChange = secondResult.getCommit().getName();
+    String sha1FirstChange = firstResult.getCommit().getName();
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstChange);
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondChange);
+    assertThat(revertChanges.get(0).get().revertOf)
+        .isEqualTo(firstResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(1).get().revertOf)
+        .isEqualTo(secondResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+
+    assertThat(revertChanges).hasSize(2);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void revertSubmissionMultipleBranches() throws Exception {
+    List<PushOneCommit.Result> resultCommits = new ArrayList<>();
+    String topic = "topic";
+    resultCommits.add(createChange(testRepo, "master", "first change", "c.txt", "message", topic));
+    testRepo.reset("HEAD~1");
+    createBranch(BranchNameKey.create(project, "other"));
+    resultCommits.add(createChange(testRepo, "other", "second change", "a.txt", "message", topic));
+    resultCommits.add(
+        createChange(testRepo, "other", "third change", "b.txt", "Other message", topic));
+    for (PushOneCommit.Result result : resultCommits) {
+      approve(result.getChangeId());
+    }
+    // submit all changes
+    gApi.changes().id(resultCommits.get(1).getChangeId()).current().submit();
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(resultCommits.get(1).getChangeId()).revertSubmission();
+    assertThat(
+            revertSubmissionInfo.revertChanges.stream()
+                .map(change -> change.created)
+                .distinct()
+                .count())
+        .isEqualTo(1);
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(2).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    String sha1FirstChange = resultCommits.get(0).getCommit().getName();
+    String sha1ThirdChange = resultCommits.get(2).getCommit().getName();
+    String sha1SecondRevert = revertChanges.get(2).current().commit(false).commit;
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstChange);
+    assertThat(revertChanges.get(2).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1ThirdChange);
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondRevert);
+
+    assertThat(revertChanges).hasSize(3);
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void revertSubmissionDependantAndUnrelatedWithMerge() throws Exception {
+    String topic = "topic";
+    PushOneCommit.Result firstResult =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic);
+    approve(firstResult.getChangeId());
+    PushOneCommit.Result secondResult =
+        createChange(testRepo, "master", "second change", "b.txt", "message", topic);
+    approve(secondResult.getChangeId());
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result thirdResult =
+        createChange(testRepo, "master", "third change", "c.txt", "message", topic);
+    approve(thirdResult.getChangeId());
+
+    gApi.changes().id(firstResult.getChangeId()).current().submit();
+
+    // put the head on the merge commit created by submitting the second and third change.
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master:merge")).call();
+    testRepo.reset("merge");
+
+    // Create another change that should be ignored. The reverts should be rebased on top of the
+    // merge commit.
+    PushOneCommit.Result fourthResult =
+        createChange(testRepo, "master", "fourth change", "d.txt", "message", topic);
+    approve(fourthResult.getChangeId());
+    gApi.changes().id(fourthResult.getChangeId()).current().submit();
+
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(secondResult.getChangeId()).revertSubmission();
+    assertThat(
+            revertSubmissionInfo.revertChanges.stream()
+                .map(change -> change.created)
+                .distinct()
+                .count())
+        .isEqualTo(1);
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    Collections.reverse(revertChanges);
+    assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(2).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    String sha1FirstRevert = revertChanges.get(0).current().commit(false).commit;
+    String sha1SecondRevert = revertChanges.get(1).current().commit(false).commit;
+    // parent of the first revert is the merged change of previous changes.
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).subject)
+        .contains("Merge");
+    // Next reverts would stack on top of the previous ones.
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstRevert);
+    assertThat(revertChanges.get(2).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondRevert);
+
+    assertThat(revertChanges).hasSize(3);
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(3);
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void revertSubmissionUnrelatedWithTwoMergeCommits() throws Exception {
+    String topic = "topic";
+    PushOneCommit.Result firstResult =
+        createChange(testRepo, "master", "first change", "a.txt", "message", topic);
+    approve(firstResult.getChangeId());
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result secondResult =
+        createChange(testRepo, "master", "second change", "b.txt", "message", topic);
+    approve(secondResult.getChangeId());
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result thirdResult =
+        createChange(testRepo, "master", "third change", "c.txt", "message", topic);
+    approve(thirdResult.getChangeId());
+
+    gApi.changes().id(firstResult.getChangeId()).current().submit();
+
+    // put the head on the most recent merge commit.
+    testRepo.git().fetch().setRefSpecs(new RefSpec("refs/heads/master:merge")).call();
+    testRepo.reset("merge");
+
+    // Create another change that should be ignored. The reverts should be rebased on top of the
+    // merge commit.
+    PushOneCommit.Result fourthResult =
+        createChange(testRepo, "master", "fourth change", "d.txt", "message", topic);
+    approve(fourthResult.getChangeId());
+    gApi.changes().id(fourthResult.getChangeId()).current().submit();
+
+    RevertSubmissionInfo revertSubmissionInfo =
+        gApi.changes().id(secondResult.getChangeId()).revertSubmission();
+    assertThat(
+            revertSubmissionInfo.revertChanges.stream()
+                .map(change -> change.created)
+                .distinct()
+                .count())
+        .isEqualTo(1);
+    List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    Collections.reverse(revertChanges);
+    assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(1).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+    assertThat(revertChanges.get(2).current().files().get("a.txt").linesDeleted).isEqualTo(1);
+    String sha1FirstRevert = revertChanges.get(0).current().commit(false).commit;
+    String sha1SecondRevert = revertChanges.get(1).current().commit(false).commit;
+    // parent of the first revert is the merged change of previous changes.
+    assertThat(revertChanges.get(0).current().commit(false).parents.get(0).subject)
+        .contains("Merge \"third change\"");
+    // Next reverts would stack on top of the previous ones.
+    assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1FirstRevert);
+    assertThat(revertChanges.get(2).current().commit(false).parents.get(0).commit)
+        .isEqualTo(sha1SecondRevert);
+
+    assertThat(revertChanges).hasSize(3);
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(3);
+  }
+
+  @Test
+  public void revertSubmissionSubjects() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    gApi.changes().id(secondResult).current().submit();
+
+    List<ChangeApi> firstRevertChanges =
+        getChangeApis(gApi.changes().id(firstResult).revertSubmission());
+    assertThat(firstRevertChanges.get(0).get().subject).isEqualTo("Revert \"first change\"");
+    assertThat(firstRevertChanges.get(1).get().subject).isEqualTo("Revert \"second change\"");
+    approve(firstRevertChanges.get(0).id());
+    approve(firstRevertChanges.get(1).id());
+    gApi.changes().id(firstRevertChanges.get(0).id()).current().submit();
+
+    List<ChangeApi> secondRevertChanges =
+        getChangeApis(gApi.changes().id(firstRevertChanges.get(0).id()).revertSubmission());
+    assertThat(secondRevertChanges.get(0).get().subject).isEqualTo("Revert^2 \"second change\"");
+    assertThat(secondRevertChanges.get(1).get().subject).isEqualTo("Revert^2 \"first change\"");
+    approve(secondRevertChanges.get(0).id());
+    approve(secondRevertChanges.get(1).id());
+    gApi.changes().id(secondRevertChanges.get(0).id()).current().submit();
+
+    List<ChangeApi> thirdRevertChanges =
+        getChangeApis(gApi.changes().id(secondRevertChanges.get(0).id()).revertSubmission());
+    assertThat(thirdRevertChanges.get(0).get().subject).isEqualTo("Revert^3 \"first change\"");
+    assertThat(thirdRevertChanges.get(1).get().subject).isEqualTo("Revert^3 \"second change\"");
+  }
+
+  @Test
+  public void revertSubmissionWithUserChangedSubjects() throws Exception {
+    String firstResult = createChange("Revert^aa", "a.txt", "message").getChangeId();
+    String secondResult = createChange("Revert", "b.txt", "other").getChangeId();
+    String thirdResult = createChange("Revert^934 \"change x\"", "c.txt", "another").getChangeId();
+    String fourthResult = createChange("Revert^934", "d.txt", "last").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    approve(thirdResult);
+    approve(fourthResult);
+    gApi.changes().id(fourthResult).current().submit();
+
+    List<ChangeApi> firstRevertChanges =
+        getChangeApis(gApi.changes().id(firstResult).revertSubmission());
+    assertThat(firstRevertChanges.get(0).get().subject).isEqualTo("Revert \"Revert^aa\"");
+    assertThat(firstRevertChanges.get(1).get().subject).isEqualTo("Revert \"Revert\"");
+    assertThat(firstRevertChanges.get(2).get().subject).isEqualTo("Revert^935 \"change x\"");
+    assertThat(firstRevertChanges.get(3).get().subject).isEqualTo("Revert \"Revert^934\"");
   }
 
   @Override
@@ -451,4 +1207,13 @@
           .create();
     }
   }
+
+  private List<ChangeApi> getChangeApis(RevertSubmissionInfo revertSubmissionInfo)
+      throws Exception {
+    List<ChangeApi> results = new ArrayList<>();
+    for (ChangeInfo changeInfo : revertSubmissionInfo.revertChanges) {
+      results.add(gApi.changes().id(changeInfo._number));
+    }
+    return results;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 07fb577..988580e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -40,7 +40,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.ProjectResetter;
@@ -48,6 +47,7 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -187,6 +187,24 @@
   }
 
   @Test
+  public void removeMember_nullInMemberInputDoesNotCauseFailure() throws Exception {
+    AccountGroup.UUID group =
+        groupOperations.newGroup().addMember(admin.id()).addMember(user.id()).create();
+    gApi.groups().id(group.get()).removeMembers(user.id().toString(), null);
+    ImmutableSet<Account.Id> members = groupOperations.group(group).get().members();
+    assertThat(members).containsExactly(admin.id());
+  }
+
+  @Test
+  public void removeMember_emptyStringInMemberInputDoesNotCauseFailure() throws Exception {
+    AccountGroup.UUID group =
+        groupOperations.newGroup().addMember(admin.id()).addMember(user.id()).create();
+    gApi.groups().id(group.get()).removeMembers(user.id().toString(), "");
+    ImmutableSet<Account.Id> members = groupOperations.group(group).get().members();
+    assertThat(members).containsExactly(admin.id());
+  }
+
+  @Test
   public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
     String username = name("user");
     Account.Id accountId = accountOperations.newAccount().username(username).create();
@@ -1265,7 +1283,7 @@
     // Newly created group is not stale
     GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
     AccountGroup.UUID groupUuid = AccountGroup.uuid(groupInfo.id);
-    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+    assertThat(stalenessChecker.check(groupUuid).isStale()).isFalse();
 
     // Manual update makes index document stale
     String groupRef = RefNames.refsGroups(groupUuid);
@@ -1406,11 +1424,11 @@
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
     // Evict group from cache to be sure that we use the index state for staleness checks.
     groupCache.evict(groupUuid);
-    assertThat(stalenessChecker.isStale(groupUuid)).isTrue();
+    assertThat(stalenessChecker.check(groupUuid).isStale()).isTrue();
 
     // Reindex fixes staleness
     groupIndexer.index(groupUuid);
-    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+    assertThat(stalenessChecker.check(groupUuid).isStale()).isFalse();
   }
 
   private void pushToGroupBranchForReviewAndSubmit(
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index a120eac..ff873dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -21,8 +21,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
index 7eb3680..744cc2a 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.acceptance.api.plugin;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.server.plugins.MissingMandatoryPluginsException;
 import org.junit.Test;
 import org.junit.runner.Description;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index eebcc5b..2801b36 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -35,10 +35,10 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
@@ -49,6 +49,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.api.projects.CommentLinkInput;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ConfigValue;
@@ -68,11 +69,16 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.project.CommentLinkInfoImpl;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
@@ -717,6 +723,191 @@
     assertCommentLinks(getConfig(), expected);
   }
 
+  @Test
+  public void projectConfigUsesLocallySetCommentlinks() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
+  @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
+  @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
+  public void projectConfigUsesCommentLinksFromGlobalAndLocal() throws Exception {
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    assertCommentLinks(getConfig(), expected);
+
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
+  @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
+  @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
+  public void localCommentLinkOverridesGlobalConfig() throws Exception {
+    String otherLink = "https://other.example.com";
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
+
+    ConfigInfo info = setConfig(project, input);
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
+  public void localCommentLinksAreInheritedFromParent() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
+    assertCommentLinks(getConfig(child), expected);
+  }
+
+  @Test
+  public void localCommentLinkOverridesParentCommentLink() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    Project.NameKey child = projectOperations.newProject().parent(project).create();
+
+    String otherLink = "https://other.example.com";
+    input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
+    info = setConfig(child, input);
+
+    expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+
+    assertCommentLinks(getConfig(child), expected);
+  }
+
+  @Test
+  public void updateExistingCommentLink() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    String otherLink = "https://other.example.com";
+    input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
+    info = setConfig(project, input);
+
+    expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(getConfig(project), expected);
+  }
+
+  @Test
+  public void removeCommentLink() throws Exception {
+    ConfigInput input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, BUGZILLA_LINK));
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+
+    input = new ConfigInput();
+    addCommentLink(input, BUGZILLA, null);
+    info = setConfig(project, input);
+
+    expected = new HashMap<>();
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(getConfig(project), expected);
+  }
+
+  @Test
+  public void cannotPushLabelDefinitionWithDuplicateValues() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText(projectOperations.project(allProjects).getConfig().toText());
+    cfg.setStringList(
+        "label",
+        "Code-Review",
+        "value",
+        ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad"));
+
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects);
+    GitUtil.fetch(repo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    repo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), repo, "Subject", "project.config", cfg.toText())
+            .to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus("invalid project configuration");
+    r.assertMessage("project.config: duplicate value \"1 lgtm\" for label \"code-review\"");
+  }
+
+  @Test
+  public void getProjectThatHasLabelDefinitionWithDuplicateValues() throws Exception {
+    // Update the definition of the Code-Review label so that it has the value "+1 LGTM" twice.
+    // This update bypasses all validation checks so that the duplicate label value doesn't get
+    // rejected.
+    Config cfg = new Config();
+    cfg.fromText(projectOperations.project(allProjects).getConfig().toText());
+    cfg.setStringList(
+        "label",
+        "Code-Review",
+        "value",
+        ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad"));
+
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(allProjects))) {
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .message("Set label with duplicate value")
+              .parent(getHead(repo.getRepository(), RefNames.REFS_CONFIG))
+              .add(ProjectConfig.PROJECT_CONFIG, cfg.toText()));
+    }
+
+    // Verify that project info can be retrieved and that the label value "+1 LGTM" appears only
+    // once.
+    ProjectInfo projectInfo = gApi.projects().name(allProjects.get()).get();
+    assertThat(projectInfo.labels.keySet()).containsExactly("Code-Review");
+    assertThat(projectInfo.labels.get("Code-Review").values)
+        .containsExactly("+1", "LGTM", " 0", "No Value", "-1", "Looks Bad");
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
   }
@@ -725,6 +916,21 @@
     assertThat(actual.commentlinks).containsExactlyEntriesIn(expected);
   }
 
+  private void addCommentLink(ConfigInput configInput, String name, String match, String link) {
+    CommentLinkInput commentLinkInput = new CommentLinkInput();
+    commentLinkInput.match = match;
+    commentLinkInput.link = link;
+    addCommentLink(configInput, name, commentLinkInput);
+  }
+
+  private void addCommentLink(
+      ConfigInput configInput, String name, CommentLinkInput commentLinkInput) {
+    if (configInput.commentLinks == null) {
+      configInput.commentLinks = new HashMap<>();
+    }
+    configInput.commentLinks.put(name, commentLinkInput);
+  }
+
   private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
     return gApi.projects().name(name.get()).config(input);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index 019df0e..dad09f9 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.RefState;
@@ -83,19 +84,19 @@
 
   @Test
   public void stalenessChecker_currentProject_notStale() throws Exception {
-    assertThat(stalenessChecker.isStale(project)).isFalse();
+    assertThat(stalenessChecker.check(project).isStale()).isFalse();
   }
 
   @Test
   public void stalenessChecker_currentProjectUpdates_isStale() throws Exception {
     updateProjectConfigWithoutIndexUpdate(project);
-    assertThat(stalenessChecker.isStale(project)).isTrue();
+    assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   @Test
   public void stalenessChecker_parentProjectUpdates_isStale() throws Exception {
     updateProjectConfigWithoutIndexUpdate(allProjects);
-    assertThat(stalenessChecker.isStale(project)).isTrue();
+    assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   @Test
@@ -106,10 +107,10 @@
       u.getConfig().getProject().setParentName(p1);
       u.save();
     }
-    assertThat(stalenessChecker.isStale(project)).isFalse();
+    assertThat(stalenessChecker.check(project).isStale()).isFalse();
 
     updateProjectConfigWithoutIndexUpdate(p1, c -> c.getProject().setParentName(p2));
-    assertThat(stalenessChecker.isStale(project)).isTrue();
+    assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   private void updateProjectConfigWithoutIndexUpdate(Project.NameKey project) throws Exception {
@@ -119,15 +120,17 @@
 
   private void updateProjectConfigWithoutIndexUpdate(
       Project.NameKey project, Consumer<ProjectConfig> update) throws Exception {
-    assertThrows(
-        UnsupportedOperationException.class,
-        () -> {
-          try (AutoCloseable ignored = disableProjectIndex()) {
-            try (ProjectConfigUpdate u = updateProject(project)) {
-              update.accept(u.getConfig());
-              u.save();
-            }
-          }
-        });
+    StorageException storageException =
+        assertThrows(
+            StorageException.class,
+            () -> {
+              try (AutoCloseable ignored = disableProjectIndex()) {
+                try (ProjectConfigUpdate u = updateProject(project)) {
+                  update.accept(u.getConfig());
+                  u.save();
+                }
+              }
+            });
+    assertThat(storageException.getCause()).isInstanceOf(UnsupportedOperationException.class);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index cf7aab4..1539334 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -19,8 +19,8 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+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.data.Permission;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index d4a4c45..717d3cc 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
 
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.testing.ConfigSuite;
@@ -2718,6 +2720,23 @@
     assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
   }
 
+  @Test
+  public void editNotAllowedAsBase() throws Exception {
+    gApi.changes().id(changeId).edit().create();
+
+    BadRequestException e =
+        assertThrows(
+            BadRequestException.class,
+            () -> getDiffRequest(changeId, CURRENT, FILE_NAME).withBase("edit").get());
+    assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
+
+    e =
+        assertThrows(
+            BadRequestException.class,
+            () -> getDiffRequest(changeId, CURRENT, FILE_NAME).withBase("0").get());
+    assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
+  }
+
   private static CommentInput createCommentInput(
       int startLine, int startCharacter, int endLine, int endCharacter, String message) {
     CommentInput comment = new CommentInput();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 32941ff..36b7265 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+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.data.Permission;
@@ -336,6 +337,9 @@
 
     assertThat(cherry.get().subject).contains(in.message);
     assertThat(cherry.get().topic).isEqualTo("someTopic-foo");
+    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
+    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(1);
+
     cherry.current().review(ReviewInput.approve());
     cherry.current().submit();
   }
@@ -415,23 +419,23 @@
     ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
 
     ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
+    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherry.get().workInProgress).isTrue();
   }
 
   @Test
   public void cherryPickToSameBranch() throws Exception {
     PushOneCommit.Result r = createChange();
+    ChangeApi change = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
     in.message = "it generates a new patch set\n\nChange-Id: " + r.getChangeId();
-    ChangeInfo cherryInfo =
-        gApi.changes()
-            .id(project.get() + "~master~" + r.getChangeId())
-            .revision(r.getCommit().name())
-            .cherryPick(in)
-            .get();
+    ChangeInfo cherryInfo = change.revision(r.getCommit().name()).cherryPick(in).get();
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
+    assertThat(cherryInfo.cherryPickOfChange).isEqualTo(change.get()._number);
+    assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
   }
@@ -624,6 +628,42 @@
   }
 
   @Test
+  public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .to("refs/for/master");
+    String t1 = project.get() + "~master~" + r1.getChangeId();
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r1.getChangeId());
+
+    BranchInput bin = new BranchInput();
+    bin.revision = r1.getCommit().getParent(0).name();
+    gApi.projects().name(project.get()).branch("foo").create(bin);
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .to("refs/for/foo");
+    String t2 = project.get() + "~foo~" + r2.getChangeId();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r1.getCommit().getFullMessage();
+    ChangeApi cherry = gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(get(t2, ALL_REVISIONS).revisions).hasSize(2);
+    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
+    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(1);
+
+    PushOneCommit.Result r3 = amendChange(r1.getChangeId(), SUBJECT, "b.txt", "b");
+    in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r3.getCommit().getFullMessage();
+    cherry = gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
+    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(2);
+  }
+
+  @Test
   public void cherryPickToExistingChange() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
@@ -1062,6 +1102,9 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "change.mergeabilityComputationBehavior",
+      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void mergeable() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
@@ -1279,10 +1322,24 @@
   public void description() throws Exception {
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-    assertDescription(r, "test");
+
+    // set description
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("foo");
+    assertDescription(r, "foo");
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
+        .isEqualTo("Description of patch set 1 set to \"foo\"");
+
+    // update description
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("bar");
+    assertDescription(r, "bar");
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
+        .isEqualTo("Description of patch set 1 changed to \"bar\"");
+
+    // remove description
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
     assertDescription(r, "");
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
+        .isEqualTo("Description \"bar\" removed from patch set 1");
   }
 
   @Test
@@ -1328,6 +1385,23 @@
   }
 
   @Test
+  public void cannotGetContentOfDirectory() throws Exception {
+    Map<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
+    PushOneCommit.Result result =
+        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThrows(
+        BadRequestException.class,
+        () ->
+            gApi.changes()
+                .id(result.getChangeId())
+                .revision(result.getCommit().name())
+                .file("dir")
+                .content());
+  }
+
+  @Test
   public void contentType() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 62a7037..6b92c16 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -25,12 +26,14 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
@@ -41,17 +44,23 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Test;
 
 public class RobotCommentsIT extends AbstractDaemonTest {
+  @Inject private TestCommentHelper testCommentHelper;
+
+  private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
+
   private static final String FILE_NAME = "file_to_fix.txt";
   private static final String FILE_NAME2 = "another_file_to_fix.txt";
   private static final String FILE_CONTENT =
@@ -60,6 +69,7 @@
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
   private String changeId;
+  private String commitId;
   private FixReplacementInfo fixReplacementInfo;
   private FixSuggestionInfo fixSuggestionInfo;
   private RobotCommentInput withFixRobotCommentInput;
@@ -74,10 +84,12 @@
             ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
     PushOneCommit.Result changeResult = push.to("refs/for/master");
     changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
 
     fixReplacementInfo = createFixReplacementInfo();
     fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfo);
-    withFixRobotCommentInput = createRobotCommentInput(fixSuggestionInfo);
+    withFixRobotCommentInput =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo);
   }
 
   @Test
@@ -91,8 +103,8 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrieved() throws Exception {
-    RobotCommentInput in = createRobotCommentInput();
-    addRobotComment(changeId, in);
+    RobotCommentInput in = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    testCommentHelper.addRobotComment(changeId, in);
 
     Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
 
@@ -103,13 +115,13 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
-    RobotCommentInput in = createRobotCommentInput();
-    addRobotComment(changeId, in);
+    RobotCommentInput in = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    testCommentHelper.addRobotComment(changeId, in);
 
     pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master");
 
-    RobotCommentInput in2 = createRobotCommentInput();
-    addRobotComment(changeId, in2);
+    RobotCommentInput in2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    testCommentHelper.addRobotComment(changeId, in2);
 
     Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).robotComments();
 
@@ -124,8 +136,8 @@
 
   @Test
   public void robotCommentsCanBeRetrievedAsList() throws Exception {
-    RobotCommentInput robotCommentInput = createRobotCommentInput();
-    addRobotComment(changeId, robotCommentInput);
+    RobotCommentInput robotCommentInput = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos =
         gApi.changes().id(changeId).current().robotCommentsAsList();
@@ -137,8 +149,8 @@
 
   @Test
   public void specificRobotCommentCanBeRetrieved() throws Exception {
-    RobotCommentInput robotCommentInput = createRobotCommentInput();
-    addRobotComment(changeId, robotCommentInput);
+    RobotCommentInput robotCommentInput = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     RobotCommentInfo robotCommentInfo = Iterables.getOnlyElement(robotCommentInfos);
@@ -150,8 +162,8 @@
 
   @Test
   public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
-    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
-    addRobotComment(changeId, in);
+    RobotCommentInput in = TestCommentHelper.createRobotCommentInputWithMandatoryFields(FILE_NAME);
+    testCommentHelper.addRobotComment(changeId, in);
 
     Map<String, List<RobotCommentInfo>> out = gApi.changes().id(changeId).current().robotComments();
     assertThat(out).hasSize(1);
@@ -160,24 +172,24 @@
   }
 
   @Test
-  public void hugeRobotCommentIsRejected() throws Exception {
-    int defaultSizeLimit = 1024 * 1024;
-    int sizeOfRest = 451;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
+  public void hugeRobotCommentIsRejected() {
+    int defaultSizeLimit = 1 << 20;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit + 1);
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
   public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
-    int defaultSizeLimit = 1024 * 1024;
-    int sizeOfRest = 451;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
+    int defaultSizeLimit = 1 << 20;
+    // Allow for a few hundred bytes in other fields.
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - 666);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     assertThat(robotCommentInfos).hasSize(1);
@@ -185,23 +197,24 @@
 
   @Test
   @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
-  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
-    int sizeLimit = 10 * 1024;
+  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() {
+    int sizeLimit = 10 << 20;
     fixReplacementInfo.replacement = getStringFor(sizeLimit);
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown).hasMessageThat().contains("limit");
   }
 
   @Test
   @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
   public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
-    int defaultSizeLimit = 1024 * 1024;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+    int defaultSizeLimit = 1 << 20;
+    fixReplacementInfo.replacement = getStringFor(2 * defaultSizeLimit);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     assertThat(robotCommentInfos).hasSize(1);
@@ -211,10 +224,10 @@
   @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
   public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
       throws Exception {
-    int defaultSizeLimit = 1024 * 1024;
-    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+    int defaultSizeLimit = 1 << 20;
+    fixReplacementInfo.replacement = getStringFor(2 * defaultSizeLimit);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     assertThat(robotCommentInfos).hasSize(1);
@@ -222,7 +235,7 @@
 
   @Test
   public void addedFixSuggestionCanBeRetrieved() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().isNotNull();
@@ -230,7 +243,7 @@
 
   @Test
   public void fixIdIsGeneratedForFixSuggestion() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().fixId().isNotEmpty();
@@ -243,7 +256,7 @@
 
   @Test
   public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     assertThatList(robotCommentInfos)
@@ -254,12 +267,13 @@
   }
 
   @Test
-  public void descriptionOfFixSuggestionIsMandatory() throws Exception {
+  public void descriptionOfFixSuggestionIsMandatory() {
     fixSuggestionInfo.description = null;
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown)
         .hasMessageThat()
         .contains(
@@ -270,7 +284,7 @@
 
   @Test
   public void addedFixReplacementCanBeRetrieved() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     assertThatList(robotCommentInfos)
@@ -281,12 +295,13 @@
   }
 
   @Test
-  public void fixReplacementsAreMandatory() throws Exception {
+  public void fixReplacementsAreMandatory() {
     fixSuggestionInfo.replacements = Collections.emptyList();
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown)
         .hasMessageThat()
         .contains(
@@ -298,7 +313,7 @@
 
   @Test
   public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -311,12 +326,13 @@
   }
 
   @Test
-  public void pathOfFixReplacementIsMandatory() throws Exception {
+  public void pathOfFixReplacementIsMandatory() {
     fixReplacementInfo.path = null;
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown)
         .hasMessageThat()
         .contains(
@@ -327,7 +343,7 @@
 
   @Test
   public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -340,12 +356,13 @@
   }
 
   @Test
-  public void rangeOfFixReplacementIsMandatory() throws Exception {
+  public void rangeOfFixReplacementIsMandatory() {
     fixReplacementInfo.range = null;
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown)
         .hasMessageThat()
         .contains(
@@ -355,17 +372,17 @@
   }
 
   @Test
-  public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
+  public void rangeOfFixReplacementNeedsToBeValid() {
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown).hasMessageThat().contains("Range (13:9 - 5:10)");
   }
 
   @Test
-  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
-      throws Exception {
+  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap() {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -382,7 +399,8 @@
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown).hasMessageThat().contains("overlap");
   }
 
@@ -403,7 +421,7 @@
         createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(1);
@@ -427,7 +445,7 @@
     withFixRobotCommentInput.fixSuggestions =
         ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(2);
@@ -454,7 +472,7 @@
         createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
@@ -462,7 +480,7 @@
 
   @Test
   public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
@@ -475,12 +493,13 @@
   }
 
   @Test
-  public void replacementStringOfFixReplacementIsMandatory() throws Exception {
+  public void replacementStringOfFixReplacementIsMandatory() {
     fixReplacementInfo.replacement = null;
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class, () -> addRobotComment(changeId, withFixRobotCommentInput));
+            BadRequestException.class,
+            () -> testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput));
     assertThat(thrown)
         .hasMessageThat()
         .contains(
@@ -496,7 +515,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -519,7 +538,7 @@
     fixReplacementInfo.replacement = "Modified content\n5";
     fixReplacementInfo.range = createRange(3, 2, 5, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
@@ -551,7 +570,7 @@
         createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
@@ -581,10 +600,12 @@
     fixReplacementInfo2.replacement = "Some other modified content\n";
     FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
 
-    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
-    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
-    addRobotComment(changeId, robotCommentInput1);
-    addRobotComment(changeId, robotCommentInput2);
+    RobotCommentInput robotCommentInput1 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput1);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput2);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -614,10 +635,12 @@
     fixReplacementInfo2.replacement = "Some other modified content\n";
     FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
 
-    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
-    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
-    addRobotComment(changeId, robotCommentInput1);
-    addRobotComment(changeId, robotCommentInput2);
+    RobotCommentInput robotCommentInput1 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput1);
+    testCommentHelper.addRobotComment(changeId, robotCommentInput2);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -646,7 +669,7 @@
     withFixRobotCommentInput.fixSuggestions =
         ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -668,7 +691,7 @@
     fixReplacementInfo.range = createRange(2, 0, 3, 0);
     fixReplacementInfo.replacement = "Modified content\n";
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
@@ -698,7 +721,7 @@
         createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
     withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
@@ -725,7 +748,7 @@
     fixReplacementInfo.range = createRange(1, 0, 2, 0);
     fixReplacementInfo.replacement = "Modified content\n";
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
     List<String> fixIds = getFixIds(robotCommentInfos);
     String fixId = Iterables.getOnlyElement(fixIds);
@@ -741,7 +764,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     // Remember patch set and add another one.
@@ -767,7 +790,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     // Remember patch set and add another one.
@@ -802,7 +825,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -824,7 +847,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -842,7 +865,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -865,7 +888,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -883,7 +906,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -906,7 +929,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -929,7 +952,7 @@
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
 
-    addRobotComment(changeId, withFixRobotCommentInput);
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
 
     List<String> fixIds = getFixIds(robotCommentInfos);
@@ -948,7 +971,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new content", r1.getChangeId())
             .to("refs/for/master");
 
-    addRobotComment(r2.getChangeId(), createRobotCommentInputWithMandatoryFields());
+    testCommentHelper.addRobotComment(
+        r2.getChangeId(), TestCommentHelper.createRobotCommentInputWithMandatoryFields(FILE_NAME));
 
     try (AutoCloseable ignored = disableNoteDb()) {
       ChangeInfo result = Iterables.getOnlyElement(query(r2.getChangeId()));
@@ -960,25 +984,141 @@
     }
   }
 
-  private static RobotCommentInput createRobotCommentInputWithMandatoryFields() {
-    RobotCommentInput in = new RobotCommentInput();
-    in.robotId = "happyRobot";
-    in.robotRunId = "1";
-    in.line = 1;
-    in.message = "nit: trailing whitespace";
-    in.path = FILE_NAME;
-    return in;
+  @Test
+  public void getFixPreviewWithNonexistingFixId() throws Exception {
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview("Non existing fixId"));
   }
 
-  private static RobotCommentInput createRobotCommentInput(
-      FixSuggestionInfo... fixSuggestionInfos) {
-    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
-    in.url = "http://www.happy-robot.com";
-    in.properties = new HashMap<>();
-    in.properties.put("key1", "value1");
-    in.properties.put("key2", "value2");
-    in.fixSuggestions = Arrays.asList(fixSuggestionInfos);
-    return in;
+  @Test
+  @Ignore
+  public void getFixPreviewForNonExistingFile() throws Exception {
+    // Not implemented yet.
+    fixReplacementInfo.path = "a_non_existent_file.txt";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview(fixId));
+  }
+
+  @Test
+  public void getFixPreview() throws Exception {
+    FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
+    fixReplacementInfoFile1.path = FILE_NAME;
+    fixReplacementInfoFile1.replacement = "some replacement code";
+    fixReplacementInfoFile1.range = createRange(3, 9, 8, 4);
+
+    FixReplacementInfo fixReplacementInfoFile2 = new FixReplacementInfo();
+    fixReplacementInfoFile2.path = FILE_NAME2;
+    fixReplacementInfoFile2.replacement = "New line\n";
+    fixReplacementInfoFile2.range = createRange(2, 0, 2, 0);
+
+    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfoFile1, fixReplacementInfoFile2);
+
+    withFixRobotCommentInput =
+        TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo);
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    Map<String, DiffInfo> fixPreview = gApi.changes().id(changeId).current().getFixPreview(fixId);
+    assertThat(fixPreview).hasSize(2);
+    assertThat(fixPreview).containsKey(FILE_NAME);
+    assertThat(fixPreview).containsKey(FILE_NAME2);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME);
+    assertThat(diff).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff).webLinks().isNull();
+    assertThat(diff).binary().isNull();
+    assertThat(diff).diffHeader().isNull();
+    assertThat(diff).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(11);
+    assertThat(diff).metaA().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaA().webLinks().isNull();
+    assertThat(diff).metaB().totalLineCount().isEqualTo(6);
+    assertThat(diff).metaB().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaB().commitId().isNull();
+    assertThat(diff).metaB().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaB().webLinks().isNull();
+
+    assertThat(diff).content().hasSize(3);
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("First line", "Second line");
+    assertThat(diff).content().element(0).linesOfA().isNull();
+    assertThat(diff).content().element(0).linesOfB().isNull();
+
+    assertThat(diff).content().element(1).commonLines().isNull();
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly(
+            "Third line", "Fourth line", "Fifth line", "Sixth line", "Seventh line", "Eighth line");
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Third linsome replacement codeth line");
+
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Ninth line", "Tenth line", "");
+    assertThat(diff).content().element(2).linesOfA().isNull();
+    assertThat(diff).content().element(2).linesOfB().isNull();
+
+    DiffInfo diff2 = fixPreview.get(FILE_NAME2);
+    assertThat(diff2).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff2).webLinks().isNull();
+    assertThat(diff2).binary().isNull();
+    assertThat(diff2).diffHeader().isNull();
+    assertThat(diff2).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff2).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diff2).metaA().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaA().webLinks().isNull();
+    assertThat(diff2).metaB().totalLineCount().isEqualTo(5);
+    assertThat(diff2).metaB().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaB().commitId().isNull();
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaB().webLinks().isNull();
+
+    assertThat(diff2).content().hasSize(3);
+    assertThat(diff2).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff2).content().element(0).linesOfA().isNull();
+    assertThat(diff2).content().element(0).linesOfB().isNull();
+
+    assertThat(diff2).content().element(1).commonLines().isNull();
+    assertThat(diff2).content().element(1).linesOfA().isNull();
+    assertThat(diff2).content().element(1).linesOfB().containsExactly("New line");
+
+    assertThat(diff2)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("2nd line", "3rd line", "");
+    assertThat(diff2).content().element(2).linesOfA().isNull();
+    assertThat(diff2).content().element(2).linesOfB().isNull();
   }
 
   private static FixSuggestionInfo createFixSuggestionInfo(
@@ -1008,15 +1148,6 @@
     return range;
   }
 
-  private void addRobotComment(String targetChangeId, RobotCommentInput robotCommentInput)
-      throws Exception {
-    ReviewInput reviewInput = new ReviewInput();
-    reviewInput.robotComments =
-        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
-    reviewInput.message = "robot comment test";
-    gApi.changes().id(targetChangeId).current().review(reviewInput);
-  }
-
   private List<RobotCommentInfo> getRobotComments() throws RestApiException {
     return gApi.changes().id(changeId).current().robotCommentsAsList();
   }
diff --git a/javatests/com/google/gerrit/acceptance/config/BUILD b/javatests/com/google/gerrit/acceptance/config/BUILD
new file mode 100644
index 0000000..350f2e6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/config/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*.java"]),
+    group = "config",
+    labels = ["config"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/config/UseGerritConfigAnnotationTest.java
similarity index 96%
rename from javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
rename to javatests/com/google/gerrit/acceptance/config/UseGerritConfigAnnotationTest.java
index d5ac2f7..f72aa74 100644
--- a/javatests/com/google/gerrit/acceptance/annotation/UseGerritConfigAnnotationTest.java
+++ b/javatests/com/google/gerrit/acceptance/config/UseGerritConfigAnnotationTest.java
@@ -12,12 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.annotation;
+package com.google.gerrit.acceptance.config;
 
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import org.junit.Test;
 
 public class UseGerritConfigAnnotationTest extends AbstractDaemonTest {
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java b/javatests/com/google/gerrit/acceptance/config/UseGlobalPluginConfigAnnotationTest.java
similarity index 96%
rename from javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
rename to javatests/com/google/gerrit/acceptance/config/UseGlobalPluginConfigAnnotationTest.java
index 44d9e46..cfad6f2 100644
--- a/javatests/com/google/gerrit/acceptance/annotation/UseGlobalPluginConfigAnnotationTest.java
+++ b/javatests/com/google/gerrit/acceptance/config/UseGlobalPluginConfigAnnotationTest.java
@@ -12,12 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.annotation;
+package com.google.gerrit.acceptance.config;
 
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GlobalPluginConfig;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 72ef981..e72bf06 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -65,7 +66,6 @@
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
-import com.google.gerrit.server.restapi.change.ChangeEdits.Put;
 import com.google.gson.reflect.TypeToken;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
@@ -142,8 +142,11 @@
   public void publishEdit() throws Exception {
     createArbitraryEditFor(changeId);
 
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email();
+    gApi.changes().id(changeId).addReviewer(in);
+
     PublishChangeEditInput publishInput = new PublishChangeEditInput();
-    publishInput.notify = NotifyHandling.NONE;
     gApi.changes().id(changeId).edit().publish(publishInput);
 
     assertThat(getEdit(changeId)).isAbsent();
@@ -160,8 +163,10 @@
     assertThat(info.messages).isNotEmpty();
     assertThat(Iterables.getLast(info.messages).tag)
         .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+    assertThat(sender.getMessages()).isNotEmpty();
 
     // Move the change to WIP, repeat, and verify.
+    sender.clear();
     gApi.changes().id(changeId).setWorkInProgress();
     createEmptyEditFor(changeId);
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
@@ -170,6 +175,7 @@
     assertThat(info.messages).isNotEmpty();
     assertThat(Iterables.getLast(info.messages).tag)
         .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   @Test
@@ -512,7 +518,7 @@
 
   @Test
   public void createAndChangeEditInOneRequestRest() throws Exception {
-    Put.Input in = new Put.Input();
+    FileContentInput in = new FileContentInput();
     in.content = RawInputUtil.create(CONTENT_NEW);
     adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
@@ -524,13 +530,21 @@
   @Test
   public void changeEditRest() throws Exception {
     createEmptyEditFor(changeId);
-    Put.Input in = new Put.Input();
+    FileContentInput in = new FileContentInput();
     in.content = RawInputUtil.create(CONTENT_NEW);
     adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
   }
 
   @Test
+  public void changeEditNoContentProvidedRest() throws Exception {
+    createEmptyEditFor(changeId);
+    adminRestSession
+        .put(urlEditFile(changeId, FILE_NAME), new FileContentInput())
+        .assertBadRequest();
+  }
+
+  @Test
   public void emptyPutRequest() throws Exception {
     createEmptyEditFor(changeId);
     adminRestSession.put(urlEditFile(changeId, FILE_NAME)).assertNoContent();
@@ -545,7 +559,7 @@
 
   @Test
   public void getFileContentRest() throws Exception {
-    Put.Input in = new Put.Input();
+    FileContentInput in = new FileContentInput();
     in.content = RawInputUtil.create(CONTENT_NEW);
     adminRestSession.putRaw(urlEditFile(changeId, FILE_NAME), in.content).assertNoContent();
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
diff --git a/javatests/com/google/gerrit/acceptance/filter/BUILD b/javatests/com/google/gerrit/acceptance/filter/BUILD
new file mode 100644
index 0000000..662c895
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/BUILD
@@ -0,0 +1,23 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob([
+        "*IT.java",
+    ]),
+    group = "filter",
+    labels = ["filter"],
+    deps = [
+        ":util",
+    ],
+)
+
+java_library(
+    name = "util",
+    testonly = True,
+    srcs = [
+        "FakeMustInitParamsFilter.java",
+        "FakeNoInitParamsFilter.java",
+    ],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/filter/FakeMustInitParamsFilter.java b/javatests/com/google/gerrit/acceptance/filter/FakeMustInitParamsFilter.java
new file mode 100644
index 0000000..89d268e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/FakeMustInitParamsFilter.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.filter;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+
+public class FakeMustInitParamsFilter implements Filter {
+
+  // `PARAM_X` and `PARAM_Y` are init param keys
+  private static final String INIT_PARAM_1 = "PARAM-1";
+  private static final String INIT_PARAM_2 = "PARAM-2";
+  // the map is used for testing
+  private static final Map<String, String> initParams = new HashMap<>();
+
+  @Override
+  public void init(FilterConfig filterConfig) throws ServletException {
+    initParams.put(INIT_PARAM_1, filterConfig.getInitParameter(INIT_PARAM_1));
+    initParams.put(INIT_PARAM_2, filterConfig.getInitParameter(INIT_PARAM_2));
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    chain.doFilter(request, response);
+  }
+
+  @Override
+  public void destroy() {
+    // do nothing.
+  }
+
+  // the function is used for testing
+  Map<String, String> getInitParams() {
+    return initParams;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/filter/FakeNoInitParamsFilter.java b/javatests/com/google/gerrit/acceptance/filter/FakeNoInitParamsFilter.java
new file mode 100644
index 0000000..6fd6366
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/FakeNoInitParamsFilter.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.filter;
+
+import java.io.IOException;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+
+public class FakeNoInitParamsFilter implements Filter {
+  @Override
+  public void init(FilterConfig filterConfig) throws ServletException {
+    // no init params in this filter.
+    // do nothing.
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    chain.doFilter(request, response);
+  }
+
+  @Override
+  public void destroy() {
+    // do nothing.
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/filter/FilterClassIT.java b/javatests/com/google/gerrit/acceptance/filter/FilterClassIT.java
new file mode 100644
index 0000000..a23c5ce
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/filter/FilterClassIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.filter;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class FilterClassIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config enableFilter() throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(
+        ""
+            + "[httpd]\n"
+            + "    filterClass = com.google.gerrit.acceptance.filter.FakeNoInitParamsFilter\n"
+            + "    filterClass = com.google.gerrit.acceptance.filter.FakeMustInitParamsFilter\n"
+            + "[filterClass \"com.google.gerrit.acceptance.filter.FakeMustInitParamsFilter\"]\n"
+            + "    PARAM-1 = hello\n"
+            + "    PARAM-2 = world\n");
+    return cfg;
+  }
+
+  @Test
+  public void filterLoad() {
+    FakeNoInitParamsFilter fakeNoInitParamsFilter =
+        server.getTestInjector().getBinding(FakeNoInitParamsFilter.class).getProvider().get();
+    Assert.assertNotNull(fakeNoInitParamsFilter);
+    FakeMustInitParamsFilter fakeMustInitParamsFilter =
+        server.getTestInjector().getBinding(FakeMustInitParamsFilter.class).getProvider().get();
+    Assert.assertNotNull(fakeMustInitParamsFilter);
+  }
+
+  @Test
+  public void filterInitParams() {
+    FakeMustInitParamsFilter fakeMustInitParamsFilter =
+        server.getTestInjector().getBinding(FakeMustInitParamsFilter.class).getProvider().get();
+    Assert.assertEquals(2, fakeMustInitParamsFilter.getInitParams().size());
+    Assert.assertEquals("hello", fakeMustInitParamsFilter.getInitParams().get("PARAM-1"));
+    Assert.assertEquals("world", fakeMustInitParamsFilter.getInitParams().get("PARAM-2"));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cab12b3..51dee72 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -52,13 +52,13 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SkipProjectClone;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
+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.data.GlobalCapability;
@@ -115,7 +115,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
@@ -485,15 +484,9 @@
 
   @Test
   public void pushForMasterWithTopic() throws Exception {
-    // specify topic in ref
     String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
-    r.assertMessage("deprecated topic syntax");
-
     // specify topic as option
-    r = pushTo("refs/for/master%topic=" + topic);
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic);
   }
@@ -514,14 +507,7 @@
   }
 
   @Test
-  public void pushForMasterWithTopicInRefExceedLimitFails() throws Exception {
-    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
-    r.assertErrorStatus("topic length exceeds the limit (2048)");
-  }
-
-  @Test
-  public void pushForMasterWithTopicAsOptionExceedLimitFails() throws Exception {
+  public void pushForMasterWithTopicExceedLimitFails() throws Exception {
     String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
     PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
     r.assertErrorStatus("topic length exceeds the limit (2048)");
@@ -605,16 +591,16 @@
   public void pushForMasterWithCc() throws Exception {
     // cc one user
     String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email());
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic + ",cc=" + user.email());
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic, ImmutableList.of(), ImmutableList.of(user));
 
     // cc several users
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%cc="
+                + ",cc="
                 + admin.email()
                 + ",cc="
                 + user.email()
@@ -632,9 +618,9 @@
     String nonExistingEmail = "non.existing@example.com";
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%cc="
+                + ",cc="
                 + admin.email()
                 + ",cc="
                 + nonExistingEmail
@@ -684,7 +670,7 @@
   public void pushForMasterWithReviewer() throws Exception {
     // add one reviewer
     String topic = "my/topic";
-    PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%r=" + user.email());
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic + ",r=" + user.email());
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, topic, user);
 
@@ -693,9 +679,9 @@
         accountCreator.create("another-user", "another.user@example.com", "Another User");
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%r="
+                + ",r="
                 + admin.email()
                 + ",r="
                 + user.email()
@@ -709,9 +695,9 @@
     String nonExistingEmail = "non.existing@example.com";
     r =
         pushTo(
-            "refs/for/master/"
+            "refs/for/master%topic="
                 + topic
-                + "%r="
+                + ",r="
                 + admin.email()
                 + ",r="
                 + nonExistingEmail
@@ -942,7 +928,7 @@
 
   @Test
   public void pushForMasterWithMessage() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%m=my_test_message");
+    PushOneCommit.Result r = pushTo("refs/for/master%m=my_test_message");
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, null);
     ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
@@ -966,7 +952,7 @@
         pushFactory.create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
     // %2C is comma; the value below tests that percent decoding happens after splitting.
     // All three ways of representing space ("%20", "+", and "_" are also exercised.
-    PushOneCommit.Result r = push.to("refs/for/master/%m=my_test%20+_message%2Cm=");
+    PushOneCommit.Result r = push.to("refs/for/master%m=my_test%20+_message%2Cm=");
     r.assertOkStatus();
 
     push =
@@ -977,7 +963,7 @@
             "b.txt",
             "anotherContent",
             r.getChangeId());
-    r = push.to("refs/for/master/%m=new_test_message");
+    r = push.to("refs/for/master%m=new_test_message");
     r.assertOkStatus();
 
     ChangeInfo ci = get(r.getChangeId(), ALL_REVISIONS);
@@ -997,7 +983,7 @@
     // Exercise percent-encoding of UTF-8, underscores, and patterns reserved by git-rev-parse.
     PushOneCommit.Result r =
         pushTo(
-            "refs/for/master/%m="
+            "refs/for/master%m="
                 + "Punctu%2E%2e%2Eation%7E%2D%40%7Bu%7D%20%7C%20%28%E2%95%AF%C2%B0%E2%96%A1%C2%B0"
                 + "%EF%BC%89%E2%95%AF%EF%B8%B5%20%E2%94%BB%E2%94%81%E2%94%BB%20%5E%5F%5E");
     r.assertOkStatus();
@@ -1018,7 +1004,7 @@
 
   @Test
   public void pushForMasterWithInvalidPercentEncodedMessage() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%m=not_percent_decodable_%%oops%20");
+    PushOneCommit.Result r = pushTo("refs/for/master%m=not_percent_decodable_%%oops%20");
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, null);
     ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
@@ -1036,7 +1022,7 @@
 
   @Test
   public void pushForMasterWithApprovals() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review");
+    PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review");
     r.assertOkStatus();
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
@@ -1054,7 +1040,7 @@
             "b.txt",
             "anotherContent",
             r.getChangeId());
-    r = push.to("refs/for/master/%l=Code-Review+2");
+    r = push.to("refs/for/master%l=Code-Review+2");
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     cr = ci.labels.get("Code-Review");
@@ -1075,7 +1061,7 @@
             "c.txt",
             "moreContent",
             r.getChangeId());
-    r = push.to("refs/for/master/%l=Code-Review+2");
+    r = push.to("refs/for/master%l=Code-Review+2");
     ci = get(r.getChangeId(), MESSAGES);
     assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
   }
@@ -1093,7 +1079,7 @@
             "b.txt",
             "anotherContent",
             r.getChangeId());
-    r = push.to("refs/for/master/%l=Code-Review+2");
+    r = push.to("refs/for/master%l=Code-Review+2");
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
@@ -1180,7 +1166,7 @@
             .create();
 
     // Push this commit as "Administrator" (requires Forge Committer Identity)
-    pushHead(testRepo, "refs/for/master/%l=Code-Review+1", false);
+    pushHead(testRepo, "refs/for/master%l=Code-Review+1", false);
 
     // Expected Code-Review votes:
     // 1. 0 from User (committer):
@@ -1228,7 +1214,7 @@
             .message(PushOneCommit.SUBJECT)
             .create();
 
-    pushHead(testRepo, "refs/for/master/%l=Code-Review+1,l=Custom-Label-1", false);
+    pushHead(testRepo, "refs/for/master%l=Code-Review+1,l=Custom-Label-1", false);
 
     ChangeInfo ci = get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
@@ -1258,13 +1244,13 @@
 
   @Test
   public void pushForMasterWithApprovals_MissingLabel() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%l=Verify");
+    PushOneCommit.Result r = pushTo("refs/for/master%l=Verify");
     r.assertErrorStatus("label \"Verify\" is not a configured label");
   }
 
   @Test
   public void pushForMasterWithApprovals_ValueOutOfRange() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review-3");
+    PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review-3");
     r.assertErrorStatus("label \"Code-Review\": -3 is not a valid value");
   }
 
@@ -1297,7 +1283,7 @@
             "b.txt",
             "anotherContent",
             r.getChangeId());
-    r = push.to("refs/for/master/%hashtag=" + hashtag2);
+    r = push.to("refs/for/master%hashtag=" + hashtag2);
     r.assertOkStatus();
     expected = ImmutableSet.of(hashtag1, hashtag2);
     hashtags = gApi.changes().id(r.getChangeId()).getHashtags();
@@ -2056,36 +2042,57 @@
     assertThat(comments.stream().map(c -> c.id)).containsExactly(c1.id, c2.id, c3.id);
     assertThat(comments.stream().map(c -> c.message))
         .containsExactly("comment1", "comment2", "comment3");
-    assertThat(getLastMessage(r.getChangeId())).isEqualTo("Uploaded patch set 3.\n\n(3 comments)");
 
-    List<String> messages =
+    /* Assert the correctness of the API messages */
+    List<ChangeMessageInfo> allMessages = getMessages(r.getChangeId());
+    List<String> messagesText = allMessages.stream().map(m -> m.message).collect(toList());
+    assertThat(messagesText)
+        .containsExactly(
+            "Uploaded patch set 1.",
+            "Uploaded patch set 2.",
+            "Uploaded patch set 3.",
+            "Patch Set 3:\n\n(3 comments)")
+        .inOrder();
+
+    /* Assert the tags - PS#2 comments do not have tags, PS#3 upload is autogenerated */
+    List<String> messagesTags = allMessages.stream().map(m -> m.tag).collect(toList());
+
+    assertThat(messagesTags.get(2)).isEqualTo("autogenerated:gerrit:newPatchSet");
+    assertThat(messagesTags.get(3)).isNull();
+
+    /* Assert the correctness of the emails sent */
+    List<String> emailMessages =
         sender.getMessages().stream()
             .map(Message::body)
             .sorted(Comparator.comparingInt(m -> m.contains("reexamine") ? 0 : 1))
             .collect(toList());
-    assertThat(messages).hasSize(2);
+    assertThat(emailMessages).hasSize(2);
 
-    assertThat(messages.get(0)).contains("Gerrit-MessageType: newpatchset");
-    assertThat(messages.get(0)).contains("I'd like you to reexamine a change");
-    assertThat(messages.get(0)).doesNotContain("Uploaded patch set 3");
+    assertThat(emailMessages.get(0)).contains("Gerrit-MessageType: newpatchset");
+    assertThat(emailMessages.get(0)).contains("I'd like you to reexamine a change");
+    assertThat(emailMessages.get(0)).doesNotContain("Uploaded patch set 3");
 
-    assertThat(messages.get(1)).contains("Gerrit-MessageType: comment");
-    assertThat(messages.get(1))
-        .containsMatch(
-            Pattern.compile(
-                // A little weird that the comment email contains this text, but it's actually
-                // what's in the ChangeMessage. Really we should fuse the emails into one, but until
-                // then, this test documents the current behavior.
-                "Uploaded patch set 3\\.\n"
-                    + "\n"
-                    + "\\(3 comments\\)\\n.*"
-                    + "PS1, Line 1:.*"
-                    + "comment1\\n.*"
-                    + "PS1, Line 1:.*"
-                    + "comment2\\n.*"
-                    + "PS2, Line 1:.*"
-                    + "comment3\\n",
-                Pattern.DOTALL));
+    assertThat(emailMessages.get(1)).contains("Gerrit-MessageType: comment");
+    assertThat(emailMessages.get(1)).contains("Patch Set 3:\n\n(3 comments)");
+    assertThat(emailMessages.get(1)).contains("PS1, Line 1:");
+    assertThat(emailMessages.get(1)).contains("PS2, Line 1:");
+
+    /* Assert the correctness of the NoteDb change meta commits */
+    List<RevCommit> commitMessages = getChangeMetaCommitsInReverseOrder(r.getChange().getId());
+    assertThat(commitMessages).hasSize(5);
+    assertThat(commitMessages.get(0).getShortMessage()).isEqualTo("Create change");
+    assertThat(commitMessages.get(1).getShortMessage()).isEqualTo("Create patch set 2");
+    assertThat(commitMessages.get(2).getShortMessage()).isEqualTo("Update patch set 2");
+    assertThat(commitMessages.get(3).getShortMessage()).isEqualTo("Create patch set 3");
+    assertThat(commitMessages.get(4).getFullMessage())
+        .isEqualTo(
+            "Update patch set 3\n"
+                + "\n"
+                + "Patch Set 3:\n"
+                + "\n"
+                + "(3 comments)\n"
+                + "\n"
+                + "Patch-set: 3\n");
   }
 
   @Test
@@ -2098,8 +2105,7 @@
 
     Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
     assertThat(comments.stream().map(c -> c.message)).containsExactly("comment1");
-    assertThat(getLastMessage(r.getChangeId()))
-        .isEqualTo("Uploaded patch set 2.\n\n(1 comment)\n\nThe message");
+    assertThat(getLastMessage(r.getChangeId())).isEqualTo("Patch Set 2:\n" + "\n" + "(1 comment)");
   }
 
   @Test
@@ -2117,16 +2123,22 @@
     amendChanges(initialHead, commits, "refs/for/master%publish-comments");
 
     Collection<CommentInfo> cs1 = getPublishedComments(id1);
+    List<ChangeMessageInfo> messages1 = getMessages(id1);
     assertThat(cs1.stream().map(c -> c.message)).containsExactly("comment1");
     assertThat(cs1.stream().map(c -> c.id)).containsExactly(c1.id);
-    assertThat(getLastMessage(id1))
-        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+    assertThat(messages1.get(0).message).isEqualTo("Uploaded patch set 1.");
+    assertThat(messages1.get(1).message)
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.");
+    assertThat(messages1.get(2).message).isEqualTo("Patch Set 2:\n\n(1 comment)");
 
     Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    List<ChangeMessageInfo> messages2 = getMessages(id2);
     assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
     assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
-    assertThat(getLastMessage(id2))
-        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+    assertThat(messages2.get(0).message).isEqualTo("Uploaded patch set 1.");
+    assertThat(messages2.get(1).message)
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.");
+    assertThat(messages2.get(2).message).isEqualTo("Patch Set 2:\n\n(1 comment)");
   }
 
   @Test
@@ -2151,7 +2163,7 @@
     assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
 
     assertThat(getLastMessage(id1)).doesNotMatch("[Cc]omment");
-    assertThat(getLastMessage(id2)).isEqualTo("Uploaded patch set 2.\n\n(1 comment)");
+    assertThat(getLastMessage(id2)).isEqualTo("Patch Set 2:\n\n(1 comment)");
   }
 
   @Test
@@ -2638,6 +2650,10 @@
         .get();
   }
 
+  private List<ChangeMessageInfo> getMessages(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(MESSAGES).messages.stream().collect(toList());
+  }
+
   private void assertThatUserIsOnlyReviewer(ChangeInfo ci, TestAccount reviewer) {
     assertThat(ci.reviewers).isNotNull();
     assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 01323a0..a0725c3 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -139,7 +139,7 @@
 
     String pushedRef = ref;
     if (!topic.isEmpty()) {
-      pushedRef += "/" + name(topic);
+      pushedRef += "%topic=" + name(topic);
     }
     String refspec = "HEAD:" + pushedRef;
 
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
new file mode 100644
index 0000000..c48eb3b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -0,0 +1,764 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+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.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.ProjectWatches;
+import com.google.gerrit.server.account.ProjectWatches.NotifyType;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.EnumSet;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Test;
+
+/** Tests account behavior when users push to accounts refs. */
+public class PushAccountIT extends AbstractDaemonTest {
+
+  @ConfigSuite.Default
+  public static Config enableSignedPushConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("receive", null, "enableSignedPush", true);
+
+    // Disable the staleness checker so that tests that verify the number of expected index events
+    // are stable.
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+
+    return cfg;
+  }
+
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Sequences seq;
+
+  @Test
+  public void pushToUserBranch() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      push.to(RefNames.refsUsers(admin.id())).assertOkStatus();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+      accountIndexedCounter.assertReindexOf(admin);
+    }
+  }
+
+  @Test
+  public void pushToUserBranchForReview() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRefName = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRefName + ":userRef");
+      allUsersRepo.reset("userRef");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      push = pushFactory.create(admin.newIdent(), allUsersRepo);
+      r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRefName);
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(admin);
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      AccountInfo info = gApi.accounts().self().get();
+      assertThat(info.email).isEqualTo(admin.email());
+      assertThat(info.name).isEqualTo(admin.fullName());
+      assertThat(info.status).isEqualTo("out-of-office");
+    }
+  }
+
+  @Test
+  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String email = "some.email@example.com";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  foo.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      requestScopeOperations.setApiUser(foo.id());
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+
+      accountIndexedCounter.assertReindexOf(foo);
+
+      AccountInfo info = gApi.accounts().self().get();
+      assertThat(info.email).isEqualTo(email);
+      assertThat(info.name).isEqualTo(foo.fullName());
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  "invalid config")
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "invalid account configuration: commit '%s' has an invalid '%s' file for account"
+                      + " '%s': Invalid config file %s in commit %s",
+                  r.getCommit().name(),
+                  AccountProperties.ACCOUNT_CONFIG,
+                  admin.id(),
+                  AccountProperties.ACCOUNT_CONFIG,
+                  r.getCommit().name()));
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String noEmail = "no.email";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "invalid account configuration: invalid preferred email '%s' for account '%s'",
+                  noEmail, admin.id()));
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
+      throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      String userRef = RefNames.refsUsers(admin.id());
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r.getChangeId()).current().submit());
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("invalid account configuration: cannot deactivate own account");
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
+
+      TestAccount foo = accountCreator.create(name("foo"));
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+          .add(allowLabel("Code-Review").ref(userRef).group(adminGroupUuid()).range(-2, 2))
+          .add(allow(Permission.SUBMIT).ref(userRef).group(adminGroupUuid()))
+          .update();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(MagicBranch.NEW_CHANGE + userRef);
+      r.assertOkStatus();
+      accountIndexedCounter.assertNoReindex();
+      assertThat(r.getChange().change().getDest().branch()).isEqualTo(userRef);
+
+      gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(r.getChangeId()).current().submit();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
+    }
+  }
+
+  @Test
+  public void pushWatchConfigToUserBranch() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config wc = new Config();
+      wc.setString(
+          ProjectWatches.PROJECT,
+          project.get(),
+          ProjectWatches.KEY_NOTIFY,
+          ProjectWatches.NotifyValue.create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              allUsersRepo,
+              "Add project watch",
+              ProjectWatches.WATCH_CONFIG,
+              wc.toText());
+      push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+      accountIndexedCounter.assertReindexOf(admin);
+
+      String invalidNotifyValue = "]invalid[";
+      wc.setString(
+          ProjectWatches.PROJECT, project.get(), ProjectWatches.KEY_NOTIFY, invalidNotifyValue);
+      push =
+          pushFactory.create(
+              admin.newIdent(),
+              allUsersRepo,
+              "Add invalid project watch",
+              ProjectWatches.WATCH_CONFIG,
+              wc.toText());
+      PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage(
+          String.format(
+              "%s: Invalid project watch of account %d for project %s: %s",
+              ProjectWatches.WATCH_CONFIG, admin.id().get(), project.get(), invalidNotifyValue));
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranch() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount oooUser = accountCreator.create("away", "away@mail.invalid", "Ambrose Way");
+      requestScopeOperations.setApiUser(oooUser.id());
+
+      // Must clone as oooUser to ensure the push is allowed.
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, oooUser);
+      fetch(allUsersRepo, RefNames.refsUsers(oooUser.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, "out-of-office");
+
+      accountIndexedCounter.clear();
+      pushFactory
+          .create(
+              oooUser.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(RefNames.refsUsers(oooUser.id()))
+          .assertOkStatus();
+
+      accountIndexedCounter.assertReindexOf(oooUser);
+
+      AccountInfo info = gApi.accounts().self().get();
+      assertThat(info.email).isEqualTo(oooUser.email());
+      assertThat(info.name).isEqualTo(oooUser.fullName());
+      assertThat(info.status).isEqualTo("out-of-office");
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  "invalid config")
+              .to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage(
+          String.format(
+              "commit '%s' has an invalid '%s' file for account '%s':"
+                  + " Invalid config file %s in commit %s",
+              r.getCommit().name(),
+              AccountProperties.ACCOUNT_CONFIG,
+              admin.id(),
+              AccountProperties.ACCOUNT_CONFIG,
+              r.getCommit().name()));
+      accountIndexedCounter.assertNoReindex();
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String noEmail = "no.email";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, noEmail);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage(
+          String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id()));
+      accountIndexedCounter.assertNoReindex();
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      String userRef = RefNames.refsUsers(foo.id());
+
+      String noEmail = "no.email";
+      accountsUpdateProvider
+          .get()
+          .update("Set Preferred Email", foo.id(), u -> u.setPreferredEmail(noEmail));
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(REGISTERED_USERS))
+          .update();
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String status = "in vacation";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS, status);
+
+      pushFactory
+          .create(
+              foo.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(userRef)
+          .assertOkStatus();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
+      assertThat(info.email).isEqualTo(noEmail);
+      assertThat(info.name).isEqualTo(foo.fullName());
+      assertThat(info.status).isEqualTo(status);
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+          .update();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      String email = "some.email@example.com";
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setString(AccountProperties.ACCOUNT, null, AccountProperties.KEY_PREFERRED_EMAIL, email);
+
+      pushFactory
+          .create(
+              foo.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(userRef)
+          .assertOkStatus();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      AccountInfo info = gApi.accounts().id(foo.id().get()).get();
+      assertThat(info.email).isEqualTo(email);
+      assertThat(info.name).isEqualTo(foo.fullName());
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.refsUsers(admin.id()) + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      PushOneCommit.Result r =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  allUsersRepo,
+                  "Update account config",
+                  AccountProperties.ACCOUNT_CONFIG,
+                  ac.toText())
+              .to(RefNames.REFS_USERS_SELF);
+      r.assertErrorStatus("invalid account configuration");
+      r.assertMessage("cannot deactivate own account");
+      accountIndexedCounter.assertNoReindex();
+    }
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      projectOperations
+          .allProjectsForUpdate()
+          .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+          .update();
+
+      TestAccount foo = accountCreator.create(name("foo"));
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isTrue();
+      String userRef = RefNames.refsUsers(foo.id());
+      accountIndexedCounter.clear();
+
+      projectOperations
+          .project(allUsers)
+          .forUpdate()
+          .add(allow(Permission.PUSH).ref(userRef).group(adminGroupUuid()))
+          .update();
+
+      TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, userRef + ":userRef");
+      allUsersRepo.reset("userRef");
+
+      Config ac = getAccountConfig(allUsersRepo);
+      ac.setBoolean(AccountProperties.ACCOUNT, null, AccountProperties.KEY_ACTIVE, false);
+
+      pushFactory
+          .create(
+              admin.newIdent(),
+              allUsersRepo,
+              "Update account config",
+              AccountProperties.ACCOUNT_CONFIG,
+              ac.toText())
+          .to(userRef)
+          .assertOkStatus();
+      accountIndexedCounter.assertReindexOf(foo);
+
+      assertThat(gApi.accounts().id(foo.id().get()).getActive()).isFalse();
+    }
+  }
+
+  @Test
+  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
+
+    String userRef = RefNames.REFS_USERS + "foo";
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  public void cannotCreateUserBranch() throws Exception {
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
+
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(allUsers)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .add(allow(Permission.PUSH).ref(RefNames.REFS_USERS + "*").group(adminGroupUuid()))
+        .update();
+
+    String userRef = RefNames.refsUsers(Account.id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory.create(admin.newIdent(), allUsersRepo).to(userRef).assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
+    Config ac = new Config();
+    try (TreeWalk tw =
+        TreeWalk.forPath(
+            allUsersRepo.getRepository(),
+            AccountProperties.ACCOUNT_CONFIG,
+            getHead(allUsersRepo.getRepository(), "HEAD").getTree())) {
+      assertThat(tw).isNotNull();
+      ac.fromText(
+          new String(
+              allUsersRepo
+                  .getRevWalk()
+                  .getObjectReader()
+                  .open(tw.getObjectId(0), OBJ_BLOB)
+                  .getBytes(),
+              UTF_8));
+    }
+    return ac;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 0ac40a0..9e44753 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
@@ -24,15 +25,13 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toMap;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 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;
@@ -59,12 +58,10 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
 import java.util.function.Predicate;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.junit.TestRepository;
@@ -1365,15 +1362,18 @@
     expectedAllRefs.addAll(expectedMetaRefs);
 
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      Map<String, Ref> all = getAllRefs(repo);
-
       PermissionBackend.ForProject forProject = newFilter(allUsers, admin);
-      assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
+      assertThat(
+              names(
+                  forProject.filter(
+                      repo.getRefDatabase().getRefs(), repo, RefFilterOptions.defaults())))
           .containsExactlyElementsIn(expectedAllRefs);
       assertThat(
-              forProject
-                  .filter(all, repo, RefFilterOptions.builder().setFilterMeta(true).build())
-                  .keySet())
+              names(
+                  forProject.filter(
+                      repo.getRefDatabase().getRefs(),
+                      repo,
+                      RefFilterOptions.builder().setFilterMeta(true).build())))
           .containsExactlyElementsIn(expectedNonMetaRefs);
     }
   }
@@ -1384,8 +1384,8 @@
     String patchSetRef = change.getPatchSetId().toRefName();
     try (AutoCloseable ignored = disableChangeIndex();
         Repository repo = repoManager.openRepository(project)) {
-      Map<String, Ref> singleRef = ImmutableMap.of(patchSetRef, repo.exactRef(patchSetRef));
-      Map<String, Ref> filteredRefs =
+      Collection<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
+      Collection<Ref> filteredRefs =
           permissionBackend
               .user(user(admin))
               .project(project)
@@ -1446,7 +1446,7 @@
   private TestRefAdvertiser.Result getReceivePackRefs() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       AdvertiseRefsHook adv =
-          ReceiveCommitsAdvertiseRefsHookChain.createForTest(queryProvider, project);
+          ReceiveCommitsAdvertiseRefsHookChain.createForTest(queryProvider, project, admin.id());
       ReceivePack rp = new ReceivePack(repo);
       rp.setAdvertiseRefsHook(adv);
       TestRefAdvertiser advertiser = new TestRefAdvertiser(repo);
@@ -1482,8 +1482,7 @@
     return AccountGroup.uuid(gApi.groups().create(groupInput).get().id);
   }
 
-  private static Map<String, Ref> getAllRefs(Repository repo) throws IOException {
-    return repo.getRefDatabase().getRefs().stream()
-        .collect(toMap(Ref::getName, Function.identity()));
+  private static Collection<String> names(Collection<Ref> refs) {
+    return refs.stream().map(Ref::getName).collect(toImmutableList());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 09da628..0efc4f9 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -18,10 +18,10 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.testing.ConfigSuite;
@@ -565,7 +565,7 @@
     // Create change as user.
     PushOneCommit push =
         pushFactory.create(user.newIdent(), repo2, "Change 2", "b.txt", "other content");
-    PushOneCommit.Result pushResult2 = push.to("refs/for/master/" + name(topic));
+    PushOneCommit.Result pushResult2 = push.to("refs/for/master%topic=" + name(topic));
     approve(pushResult2.getChangeId());
 
     // Submit the topic, 2 changes with the different author.
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 283c95f..0715b7e 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -109,7 +109,7 @@
         .git()
         .push()
         .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master%topic=" + name("topic-foo")))
         .call();
 
     subRepo.reset(c.getId());
@@ -134,7 +134,7 @@
         .git()
         .push()
         .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master%topic=" + name("topic-foo")))
         .call();
 
     String id1 = getChangeId(subRepo, c1).get();
@@ -212,7 +212,7 @@
         .git()
         .push()
         .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master%topic=" + name("topic-foo")))
         .call();
 
     subRepo.reset(c.getId());
@@ -237,7 +237,7 @@
         .git()
         .push()
         .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master%topic=" + name("topic-foo")))
         .call();
 
     RevCommit c4 =
@@ -252,7 +252,7 @@
         .git()
         .push()
         .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master/" + name("topic-foo")))
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master%topic=" + name("topic-foo")))
         .call();
 
     String id1 = getChangeId(subRepo, c1).get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 52de5ad..09680fb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -743,7 +743,7 @@
             .add(
                 new ExceptionHook() {
                   @Override
-                  public boolean shouldRetry(Throwable t) {
+                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
                     return true;
                   }
                 })) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index faaba06..a3c0295 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -29,11 +29,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 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.data.GlobalCapability;
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
index 8e5eaa4..eb125a0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -21,8 +21,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.common.ChangeInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 8a284d9..83bc3eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -84,6 +84,7 @@
           RestCall.post("/changes/%s/rebase"),
           RestCall.post("/changes/%s/restore"),
           RestCall.post("/changes/%s/revert"),
+          RestCall.post("/changes/%s/revert_submission"),
           RestCall.get("/changes/%s/pure_revert"),
           RestCall.post("/changes/%s/submit"),
           RestCall.get("/changes/%s/submitted_together"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java
index d60148e..dd6eb7a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginsRemoteAdminRestApiBindingsIT.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.common.RawInputUtil;
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f48a603..55eeaf4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -73,6 +74,7 @@
           RestCall.get("/projects/%s/branches"),
           RestCall.post("/projects/%s/branches:delete"),
           RestCall.put("/projects/%s/branches/new-branch"),
+          RestCall.get("/projects/%s/labels"),
           RestCall.get("/projects/%s/tags"),
           RestCall.post("/projects/%s/tags:delete"),
           RestCall.put("/projects/%s/tags/new-tag"),
@@ -80,7 +82,9 @@
               // GET /projects/<project>/branches/<branch>/commits is not implemented
               .expectedResponseCode(SC_NOT_FOUND)
               .build(),
-          RestCall.get("/projects/%s/dashboards"));
+          RestCall.get("/projects/%s/dashboards"),
+          RestCall.put("/projects/%s/labels/new-label"),
+          RestCall.post("/projects/%s/labels/"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -158,6 +162,18 @@
   private static final ImmutableList<RestCall> COMMIT_FILE_ENDPOINTS =
       ImmutableList.of(RestCall.get("/projects/%s/commits/%s/files/%s/content"));
 
+  /**
+   * Label REST endpoints to be tested, each URL contains placeholders for the project identifier
+   * and the label name.
+   */
+  private static final ImmutableList<RestCall> LABEL_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/labels/%s"),
+          RestCall.put("/projects/%s/labels/%s"),
+
+          // Label deletion must be tested last
+          RestCall.delete("/projects/%s/labels/%s"));
+
   private static final String FILENAME = "test.txt";
   @Inject private ProjectOperations projectOperations;
 
@@ -212,6 +228,13 @@
         adminRestSession, COMMIT_FILE_ENDPOINTS, project.get(), commit, FILENAME);
   }
 
+  @Test
+  public void labelEndpoints() throws Exception {
+    String label = "Foo-Review";
+    configLabel(label, LabelFunction.NO_OP);
+    RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
+  }
+
   private String createAndSubmitChange(String filename) throws Exception {
     RevCommit c =
         testRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.java
index 6d140c6..5573ad7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/RootCollectionsRestApiBindingsIT.java
@@ -20,7 +20,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 12fe263..cdffa3c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -44,7 +44,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -52,6 +51,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
+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;
@@ -614,11 +614,11 @@
   @Test
   public void submitWithHiddenBranchInSameTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
-    PushOneCommit.Result visible = createChange("refs/for/master/" + name("topic"));
+    PushOneCommit.Result visible = createChange("refs/for/master%topic=" + name("topic"));
     Change.Id num = visible.getChange().getId();
 
     createBranch(BranchNameKey.create(project, "hidden"));
-    PushOneCommit.Result hidden = createChange("refs/for/hidden/" + name("topic"));
+    PushOneCommit.Result hidden = createChange("refs/for/hidden%topic=" + name("topic"));
     approve(hidden.getChangeId());
     projectOperations
         .project(project)
@@ -789,8 +789,8 @@
 
     // create and submit 2 changes with the same topic
     String topic = name("topic");
-    PushOneCommit.Result change1 = createChange("refs/for/master/" + topic);
-    PushOneCommit.Result change2 = createChange("refs/for/master/" + topic);
+    PushOneCommit.Result change1 = createChange("refs/for/master%topic=" + topic);
+    PushOneCommit.Result change2 = createChange("refs/for/master%topic=" + topic);
     approve(change1.getChangeId());
     submit(change2.getChangeId());
     assertMerged(change1.getChangeId());
@@ -938,7 +938,7 @@
     testRepo
         .git()
         .push()
-        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
+        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable%topic=" + name("topic")))
         .call();
 
     // Merge the fix into master.
@@ -955,7 +955,7 @@
     testRepo
         .git()
         .push()
-        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master/" + name("topic")))
+        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master%topic=" + name("topic")))
         .call();
 
     // Submit together.
@@ -1205,7 +1205,9 @@
   }
 
   @Test
-  @GerritConfig(name = "index.reindexAfterRefUpdate", value = "true")
+  @GerritConfig(
+      name = "change.mergeabilityComputationBehavior",
+      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void submitSchedulesOpenChangesOfSameBranchForReindexing() throws Throwable {
     // Create a merged change.
     PushOneCommit push =
@@ -1475,6 +1477,6 @@
   protected PushOneCommit.Result createChange(
       String subject, String fileName, String content, String topic) throws Throwable {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
-    return push.to("refs/for/master/" + name(topic));
+    return push.to("refs/for/master%topic=" + name(topic));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index a4fa84b..f77552d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -102,11 +102,11 @@
     PushOneCommit.Result change1 =
         pushFactory
             .create(admin.newIdent(), testRepo, "Change 1", "a", "a")
-            .to("refs/for/master/" + name("topic"));
+            .to("refs/for/master%topic=" + name("topic"));
 
     PushOneCommit push2 = pushFactory.create(admin.newIdent(), testRepo, "Change 2", "b", "b");
     push2.noParents();
-    PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
+    PushOneCommit.Result change2 = push2.to("refs/for/master%topic=" + name("topic"));
     change2.assertOkStatus();
 
     approve(change1.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index dda7bbd..911a04d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -63,11 +63,40 @@
     return gApi.changes().id(id).revision(1).actions();
   }
 
+  protected Map<String, ActionInfo> getChangeActions(String id) throws Exception {
+    return gApi.changes().id(id).get().actions;
+  }
+
   protected String getETag(String id) throws Exception {
     return gApi.changes().id(id).current().etag();
   }
 
   @Test
+  public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
+    String changeId = createChangeWithTopic().getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId).current().submit();
+    Map<String, ActionInfo> actions = getChangeActions(changeId);
+    assertThat(actions).containsKey("revert");
+    assertThat(actions).doesNotContainKey("revert_submission");
+  }
+
+  @Test
+  public void changeActionTwoMergedChangesHaveReverts() throws Exception {
+    String changeId1 = createChangeWithTopic().getChangeId();
+    String changeId2 = createChangeWithTopic().getChangeId();
+    gApi.changes().id(changeId1).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId2).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId2).current().submit();
+    Map<String, ActionInfo> actions1 = getChangeActions(changeId1);
+    assertThat(actions1).containsKey("revert");
+    assertThat(actions1).containsKey("revert_submission");
+    Map<String, ActionInfo> actions2 = getChangeActions(changeId2);
+    assertThat(actions2).containsKey("revert");
+    assertThat(actions2).containsKey("revert_submission");
+  }
+
+  @Test
   public void revisionActionsOneChangePerTopicUnapproved() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     Map<String, ActionInfo> actions = getActions(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index a1167ed..3f80dd1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -606,6 +607,102 @@
     }
   }
 
+  @Test
+  public void createChangeWithSubmittedMergeSource() throws Exception {
+    // Provide coverage for a performance optimization in CommitsCollection#canRead.
+    BranchInput branchInput = new BranchInput();
+    String mergeTarget = "refs/heads/new-branch";
+    RevCommit startCommit = projectOperations.project(project).getHead("master");
+
+    branchInput.revision = startCommit.name();
+    branchInput.ref = mergeTarget;
+
+    gApi.projects().name(project.get()).branch(mergeTarget).create(branchInput);
+
+    // To create a merge commit, create two changes from the same parent,
+    // and submit them one after the other.
+    PushOneCommit.Result result1 =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "subject1", ImmutableMap.of("file1.txt", "content 1"))
+            .to("refs/for/master");
+    result1.assertOkStatus();
+
+    testRepo.branch("HEAD").update(startCommit);
+    PushOneCommit.Result result2 =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "subject2", ImmutableMap.of("file2.txt", "content 2"))
+            .to("refs/for/master");
+    result2.assertOkStatus();
+
+    ReviewInput reviewInput = ReviewInput.approve().label("Code-Review", 2);
+
+    gApi.changes().id(result1.getChangeId()).revision("current").review(reviewInput);
+    gApi.changes().id(result1.getChangeId()).revision("current").submit();
+
+    gApi.changes().id(result2.getChangeId()).revision("current").review(reviewInput);
+    gApi.changes().id(result2.getChangeId()).revision("current").submit();
+
+    String mergeRev = gApi.projects().name(project.get()).branch("master").get().revision;
+    RevCommit mergeCommit = projectOperations.project(project).getHead("master");
+    assertThat(mergeCommit.getParents().length).isEqualTo(2);
+
+    testRepo.git().fetch().call();
+    testRepo.branch("HEAD").update(mergeCommit);
+    PushOneCommit.Result result3 =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "subject3", ImmutableMap.of("file1.txt", "content 3"))
+            .to("refs/for/master");
+    result2.assertOkStatus();
+    gApi.changes().id(result3.getChangeId()).revision("current").review(reviewInput);
+    gApi.changes().id(result3.getChangeId()).revision("current").submit();
+
+    // Now master doesn't point directly to mergeRev
+    ChangeInput in = new ChangeInput();
+    in.branch = mergeTarget;
+    in.merge = new MergeInput();
+    in.project = project.get();
+    in.merge.source = mergeRev;
+    in.subject = "propagate merge";
+
+    gApi.changes().create(in);
+  }
+
+  @Test
+  public void createChangeWithSourceBranch() throws Exception {
+    changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+
+    // create a merge change from branchA to master in gerrit
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = "branchA";
+    in.subject = "message";
+    in.status = ChangeStatus.NEW;
+    MergeInput mergeInput = new MergeInput();
+
+    String mergeRev = gApi.projects().name(project.get()).branch("branchB").get().revision;
+    mergeInput.source = mergeRev;
+    in.merge = mergeInput;
+
+    assertCreateSucceeds(in);
+
+    // Succeeds with a visible branch
+    in.merge.sourceBranch = "refs/heads/branchB";
+    gApi.changes().create(in);
+
+    // Make it invisible
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(READ).ref(in.merge.sourceBranch).group(REGISTERED_USERS))
+        .update();
+
+    // Now it fails.
+    assertThrows(BadRequestException.class, () -> gApi.changes().create(in));
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 3030b02..61dc4d4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 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.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -58,6 +59,9 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "change.mergeabilityComputationBehavior",
+      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void indexChangeAfterOwnerLosesVisibility() throws Exception {
     // Create a test group with 2 users as members
     TestAccount user2 = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 063f1a0..37b1713 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -22,10 +22,10 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.LabelFunction;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
index 1a3c10f..f5cca1b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index 73f10e5..a63d60a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -217,7 +217,7 @@
             "new.txt",
             "Conflicting line #2",
             ImmutableList.of(f.getCommit()),
-            "refs/for/master/" + name("topic1"));
+            "refs/for/master%topic=" + name("topic1"));
 
     PushOneCommit.Result h = createChange(project2, "H");
     PushOneCommit.Result i =
@@ -231,7 +231,7 @@
             "new.txt",
             "Sadly conflicting topic-wise",
             ImmutableList.of(i.getCommit(), j.getCommit()),
-            "refs/for/master/" + name("topic1"));
+            "refs/for/master%topic=" + name("topic1"));
 
     approve(h.getChangeId());
     approve(i.getChangeId());
@@ -253,7 +253,7 @@
             "new.txt",
             "Resolving conflicts again",
             ImmutableList.of(c.getCommit(), g.getCommit()),
-            "refs/for/master/" + name("topic1"));
+            "refs/for/master%topic=" + name("topic1"));
 
     approve(l.getChangeId());
     assertChangeSetMergeable(l.getChange(), true);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 8e0042c..1bd2d99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -27,8 +27,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -39,7 +39,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
@@ -397,19 +396,13 @@
     requestScopeOperations.setApiUser(user1.id());
     String changeId1 = createChangeFromApi();
 
-    requestScopeOperations.setApiUser(reviewer1.id());
-    reviewChange(changeId1);
+    reviewChange(changeId1, reviewer1);
 
-    requestScopeOperations.setApiUser(user1.id());
     String changeId2 = createChangeFromApi();
 
-    requestScopeOperations.setApiUser(reviewer1.id());
-    reviewChange(changeId2);
+    reviewChange(changeId2, reviewer1);
+    reviewChange(changeId2, reviewer2);
 
-    requestScopeOperations.setApiUser(reviewer2.id());
-    reviewChange(changeId2);
-
-    requestScopeOperations.setApiUser(user1.id());
     String changeId3 = createChangeFromApi();
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId3, null, 4);
     assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
@@ -440,13 +433,11 @@
 
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    requestScopeOperations.setApiUser(foo1.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo1);
     assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    requestScopeOperations.setApiUser(foo2.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo2);
     assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
 
     assertReviewers(
@@ -466,12 +457,10 @@
 
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    requestScopeOperations.setApiUser(foo1.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo1);
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    requestScopeOperations.setApiUser(foo2.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo2);
 
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
@@ -488,12 +477,10 @@
 
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    requestScopeOperations.setApiUser(foo1.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo1);
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    requestScopeOperations.setApiUser(foo2.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo2);
 
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
@@ -514,12 +501,10 @@
 
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    requestScopeOperations.setApiUser(foo1.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo1);
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    requestScopeOperations.setApiUser(foo2.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, foo2);
 
     assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
@@ -575,8 +560,7 @@
     String changeIdReviewed = createChangeFromApi();
 
     TestAccount reviewer = accountCreator.create("newReviewer");
-    requestScopeOperations.setApiUser(reviewer.id());
-    reviewChange(changeIdReviewed);
+    reviewChange(changeIdReviewed, reviewer);
 
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, "new", 4);
     assertThat(reviewers.stream().map(r -> r.account._accountId).collect(toList()))
@@ -624,10 +608,8 @@
     return user(name, fullName, name);
   }
 
-  private void reviewChange(String changeId) throws RestApiException {
-    ReviewInput ri = new ReviewInput();
-    ri.label("Code-Review", 1);
-    gApi.changes().id(changeId).current().review(ri);
+  private void reviewChange(String changeId, TestAccount reviewer) throws RestApiException {
+    gApi.changes().id(changeId).addReviewer(reviewer.id().toString());
   }
 
   private String createChangeFromApi() throws RestApiException {
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index ae17be0..8baeffc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.Ordering;
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
@@ -26,7 +27,6 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
-import org.eclipse.jgit.util.Base64;
 import org.junit.Test;
 
 public class ListCachesIT extends AbstractDaemonTest {
@@ -78,7 +78,7 @@
   public void listCacheNamesTextList() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/caches/?format=TEXT_LIST");
     r.assertOK();
-    String result = new String(Base64.decode(r.getEntityContent()), UTF_8.name());
+    String result = new String(BaseEncoding.base64().decode(r.getEntityContent()), UTF_8);
     List<String> list = Arrays.asList(result.split("\n"));
     assertThat(list).contains("accounts");
     assertThat(list).contains("projects");
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 1d87ca1..14a04cd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -18,9 +18,9 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -169,7 +169,8 @@
     assertThat(i.change.updateDelay).isEqualTo(300);
     assertThat(i.change.disablePrivateChanges).isNull();
     assertThat(i.change.submitWholeTopic).isNull();
-    assertThat(i.change.excludeMergeableInChangeInfo).isNull();
+    assertThat(i.change.mergeabilityComputationBehavior)
+        .isEqualTo("API_REF_UPDATED_AND_CHANGE_REINDEX");
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
@@ -201,9 +202,9 @@
   }
 
   @Test
-  @GerritConfig(name = "change.api.excludeMergeableInChangeInfo", value = "true")
-  public void serverConfigWithExcludeMergeableInChangeInfo() throws Exception {
+  @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
+  public void mergeabilityComputationBehavior_neverCompute() throws Exception {
     ServerInfo i = gApi.config().server().getInfo();
-    assertThat(i.change.excludeMergeableInChangeInfo).isTrue();
+    assertThat(i.change.mergeabilityComputationBehavior).isEqualTo("NEVER");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/GroupsIT.java
deleted file mode 100644
index e153e561..0000000
--- a/javatests/com/google/gerrit/acceptance/rest/group/GroupsIT.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License
-
-package com.google.gerrit.acceptance.rest.group;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.RestResponse;
-import org.junit.Test;
-
-public class GroupsIT extends AbstractDaemonTest {
-  @Test
-  public void invalidQueryOptions() throws Exception {
-    RestResponse r = adminRestSession.put("/groups/?query=foo&query2=bar");
-    r.assertBadRequest();
-    assertThat(r.getEntityContent())
-        .isEqualTo("\"query\" and \"query2\" options are mutually exclusive");
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
new file mode 100644
index 0000000..d8132b7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.group;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gson.reflect.TypeToken;
+import java.util.Map;
+import org.junit.Test;
+
+public class ListGroupsIT extends AbstractDaemonTest {
+  @Test
+  public void listAllGroups() throws Exception {
+    RestResponse response = adminRestSession.get("/groups/");
+    response.assertOK();
+
+    Map<String, GroupInfo> groupMap =
+        newGson()
+            .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
+    assertThat(groupMap.keySet()).containsExactly("Administrators", "Non-Interactive Users");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 91a10ca..3e9b1f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -604,7 +604,7 @@
   }
 
   @Test
-  public void syncCreateGroupPermission() throws Exception {
+  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
     // Grant CREATE_GROUP to Registered Users
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSection = newAccessSectionInfo();
@@ -642,6 +642,44 @@
   }
 
   @Test
+  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
+      throws Exception {
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Grant CREATE_GROUP to Administrators
+    accessInput = newProjectAccessInput();
+    accessSection = newAccessSectionInfo();
+    createGroup = newPermissionInfo();
+    createGroup.rules.put(adminGroupUuid().get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThat(local).isNotNull();
+    assertThat(local).containsKey(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    assertThat(permissions).hasSize(2);
+    // READ is the default permission and should be preserved by the syncer
+    assertThat(permissions.keySet()).containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThat(rules.keySet())
+        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
+    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
+    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
+  }
+
+  @Test
   public void addAccessSectionForInvalidRef() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index e082559..b50a12b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -6,6 +6,7 @@
     group = f[:f.index(".")],
     labels = ["rest"],
     deps = [
+        ":labelassert",
         ":project",
         ":push_tag_util",
         ":refassert",
@@ -14,6 +15,19 @@
 ) for f in glob(["*IT.java"])]
 
 java_library(
+    name = "labelassert",
+    srcs = [
+        "LabelAssert.java",
+    ],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib/truth",
+    ],
+)
+
+java_library(
     name = "refassert",
     srcs = [
         "RefAssert.java",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
new file mode 100644
index 0000000..57a1e56
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -0,0 +1,611 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class CreateLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .label("Foo-Review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .label("Foo-Review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotCreateLabelIfNameDoesntMatch() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "Foo";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Bar").create(input));
+    assertThat(thrown).hasMessageThat().contains("name in input must match name in URL");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("Code-Review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatConflicts() throws Exception {
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("code-review")
+                    .create(new LabelDefinitionInput()));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("label code-review conflicts with existing label Code-Review");
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("INVALID_NAME").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid name: INVALID_NAME");
+  }
+
+  @Test
+  public void cannotCreateLabelWithoutValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("values are required");
+
+    input.values = ImmutableMap.of();
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("values are required");
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("invalidValue", "description");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
+  }
+
+  @Test
+  public void cannotCreateLabelWithValuesThatHaveEmptyDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
+  }
+
+  @Test
+  public void cannotCreateLabelWithDuplicateValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            "+1", "Looks Good", "1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("duplicate value: 1");
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+    input.defaultValue = 5;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
+  }
+
+  @Test
+  public void cannotCreateLabelWithUnknownFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+    input.function = "UnknownFuction";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
+  }
+
+  @Test
+  public void cannotCreateLabelWithInvalidBranch() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+    input.branches = ImmutableList.of("refs heads master");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
+  }
+
+  @Test
+  public void createWithNameAndValuesOnly() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.name).isEqualTo("Foo");
+    assertThat(createdLabel.projectName).isEqualTo(project.get());
+    assertThat(createdLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
+    assertThat(createdLabel.values).containsExactlyEntriesIn(input.values);
+    assertThat(createdLabel.defaultValue).isEqualTo(0);
+    assertThat(createdLabel.branches).isNull();
+    assertThat(createdLabel.canOverride).isTrue();
+    assertThat(createdLabel.copyAnyScore).isNull();
+    assertThat(createdLabel.copyMinScore).isNull();
+    assertThat(createdLabel.copyMaxScore).isNull();
+    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
+    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(createdLabel.allowPostSubmit).isTrue();
+    assertThat(createdLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void createWithFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.function = LabelFunction.NO_OP.getFunctionName();
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
+  }
+
+  @Test
+  public void functionEmptyAfterTrim() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.function = " ";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
+  }
+
+  @Test
+  public void valuesAndDescriptionsAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            " 2 ",
+            " Looks Very Good ",
+            " +1 ",
+            " Looks Good ",
+            " 0 ",
+            " Don't Know ",
+            " -1 ",
+            " Looks Bad ",
+            " -2 ",
+            " Looks Very Bad ");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+  }
+
+  @Test
+  public void createWithDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.defaultValue = 1;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.defaultValue).isEqualTo(input.defaultValue);
+  }
+
+  @Test
+  public void createWithBranches() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    // Branches can be full ref, ref pattern or regular expression.
+    input.branches =
+        ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches).containsExactlyElementsIn(input.branches);
+  }
+
+  @Test
+  public void branchesAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches =
+        ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches)
+        .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void emptyBranchesAreIgnored() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = ImmutableList.of("refs/heads/master", "", " ");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches).containsExactly("refs/heads/master");
+  }
+
+  @Test
+  public void branchesAreAutomaticallyPrefixedWithRefsHeads() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = ImmutableList.of("master", "refs/meta/config");
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+    assertThat(createdLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void createWithCanOverride() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.canOverride = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.canOverride).isTrue();
+  }
+
+  @Test
+  public void createWithoutCanOverride() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.canOverride = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.canOverride).isNull();
+  }
+
+  @Test
+  public void createWithCopyAnyScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAnyScore = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAnyScore).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAnyScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAnyScore = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAnyScore).isNull();
+  }
+
+  @Test
+  public void createWithCopyMinScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMinScore = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMinScore).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyMinScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMinScore = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMinScore).isNull();
+  }
+
+  @Test
+  public void createWithCopyMaxScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMaxScore = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMaxScore).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyMaxScore() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyMaxScore = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyMaxScore).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresIfNoChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfNoChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoChange).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresIfNoCodeChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoCodeChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfNoCodeChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfNoCodeChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresOnTrivialRebase() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnTrivialRebase = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresOnTrivialRebase() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnTrivialRebase = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
+  }
+
+  @Test
+  public void createWithCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnMergeFirstParentUpdate = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresOnMergeFirstParentUpdate = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+  }
+
+  @Test
+  public void createWithAllowPostSubmit() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.allowPostSubmit = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.allowPostSubmit).isTrue();
+  }
+
+  @Test
+  public void createWithoutAllowPostSubmit() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.allowPostSubmit = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.allowPostSubmit).isNull();
+  }
+
+  @Test
+  public void createWithIgnoreSelfApproval() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.ignoreSelfApproval = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.ignoreSelfApproval).isTrue();
+  }
+
+  @Test
+  public void createWithoutIgnoreSelfApproval() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.ignoreSelfApproval = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    gApi.projects().name(project.get()).label("Foo").create(input);
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update label");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.commitMessage = "Add Foo Label";
+    gApi.projects().name(project.get()).label("Foo").create(input);
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.commitMessage = " Add Foo Label ";
+    gApi.projects().name(project.get()).label("Foo").create(input);
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Add Foo Label");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 6b2baa7..0ba4f16 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -30,9 +30,9 @@
 import com.google.common.collect.Sets;
 import com.google.common.net.HttpHeaders;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseLocalDisk;
+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.data.GlobalCapability;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
new file mode 100644
index 0000000..c916285
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class DeleteLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void nonExisting() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Non-Existing-Review").delete());
+    assertThat(thrown).hasMessageThat().contains("Not found: Non-Existing-Review");
+  }
+
+  @Test
+  public void delete() throws Exception {
+    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).label("Code-Review").get());
+    assertThat(thrown).hasMessageThat().contains("Not found: Code-Review");
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Delete label");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    gApi.projects().name(allProjects.get()).label("Code-Review").delete("Delete Code-Review label");
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Delete Code-Review label");
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    gApi.projects()
+        .name(allProjects.get())
+        .label("Code-Review")
+        .delete(" Delete Code-Review label ");
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Delete Code-Review label");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
new file mode 100644
index 0000000..9f98490
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class GetLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void notFound() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).label("Foo-Review").get());
+    assertThat(thrown).hasMessageThat().contains("Not found: Foo-Review");
+  }
+
+  @Test
+  public void allProjectsCodeReviewLabel() throws Exception {
+    LabelDefinitionInfo codeReviewLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").get();
+    LabelAssert.assertCodeReviewLabel(codeReviewLabel);
+  }
+
+  @Test
+  public void labelWithDefaultValue() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set default value
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setDefaultValue((short) 1);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.defaultValue).isEqualTo(1);
+  }
+
+  @Test
+  public void labelLimitedToBranches() throws Exception {
+    configLabel(
+        "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.branches).containsExactly("refs/heads/master", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void labelWithoutRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // unset rules which are enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCanOverride(false);
+      labelType.setCopyAllScoresIfNoChange(false);
+      labelType.setAllowPostSubmit(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.canOverride).isNull();
+    assertThat(fooLabel.copyAnyScore).isNull();
+    assertThat(fooLabel.copyMinScore).isNull();
+    assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(fooLabel.allowPostSubmit).isNull();
+    assertThat(fooLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void labelWithAllRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set rules which are not enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAnyScore(true);
+      labelType.setCopyMinScore(true);
+      labelType.setCopyMaxScore(true);
+      labelType.setCopyAllScoresIfNoCodeChange(true);
+      labelType.setCopyAllScoresOnTrivialRebase(true);
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      labelType.setIgnoreSelfApproval(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
+    assertThat(fooLabel.canOverride).isTrue();
+    assertThat(fooLabel.copyAnyScore).isTrue();
+    assertThat(fooLabel.copyMinScore).isTrue();
+    assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+    assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.ignoreSelfApproval).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
new file mode 100644
index 0000000..7998ecb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+
+public class LabelAssert {
+  public static void assertCodeReviewLabel(LabelDefinitionInfo codeReviewLabel) {
+    assertThat(codeReviewLabel.name).isEqualTo("Code-Review");
+    assertThat(codeReviewLabel.projectName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+    assertThat(codeReviewLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
+    assertThat(codeReviewLabel.values)
+        .containsExactly(
+            "+2",
+            "Looks good to me, approved",
+            "+1",
+            "Looks good to me, but someone else must approve",
+            " 0",
+            "No score",
+            "-1",
+            "I would prefer this is not merged as is",
+            "-2",
+            "This shall not be merged");
+    assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
+    assertThat(codeReviewLabel.branches).isNull();
+    assertThat(codeReviewLabel.canOverride).isTrue();
+    assertThat(codeReviewLabel.copyAnyScore).isNull();
+    assertThat(codeReviewLabel.copyMinScore).isTrue();
+    assertThat(codeReviewLabel.copyMaxScore).isNull();
+    assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(codeReviewLabel.allowPostSubmit).isTrue();
+    assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
+  }
+
+  private LabelAssert() {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
new file mode 100644
index 0000000..d2539e5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class ListLabelsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).labels().get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.projects().name(project.get()).labels().get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void noLabels() throws Exception {
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void allProjectsLabels() throws Exception {
+    List<LabelDefinitionInfo> labels = gApi.projects().name(allProjects.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review");
+
+    LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
+    LabelAssert.assertCodeReviewLabel(codeReviewLabel);
+  }
+
+  @Test
+  public void labelsAreSortedByName() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    configLabel("bar", LabelFunction.NO_OP);
+    configLabel("baz", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("bar", "baz", "foo").inOrder();
+  }
+
+  @Test
+  public void labelWithDefaultValue() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set default value
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setDefaultValue((short) 1);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.defaultValue).isEqualTo(1);
+  }
+
+  @Test
+  public void labelLimitedToBranches() throws Exception {
+    configLabel(
+        "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.branches).containsExactly("refs/heads/master", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void labelWithoutRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // unset rules which are enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCanOverride(false);
+      labelType.setCopyAllScoresIfNoChange(false);
+      labelType.setAllowPostSubmit(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.canOverride).isNull();
+    assertThat(fooLabel.copyAnyScore).isNull();
+    assertThat(fooLabel.copyMinScore).isNull();
+    assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+    assertThat(fooLabel.allowPostSubmit).isNull();
+    assertThat(fooLabel.ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void labelWithAllRules() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    // set rules which are not enabled by default
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAnyScore(true);
+      labelType.setCopyMinScore(true);
+      labelType.setCopyMaxScore(true);
+      labelType.setCopyAllScoresIfNoCodeChange(true);
+      labelType.setCopyAllScoresOnTrivialRebase(true);
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      labelType.setIgnoreSelfApproval(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.canOverride).isTrue();
+    assertThat(fooLabel.copyAnyScore).isTrue();
+    assertThat(fooLabel.copyMinScore).isTrue();
+    assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
+    assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
+    assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
+    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+    assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.ignoreSelfApproval).isTrue();
+  }
+
+  @Test
+  public void withInheritedLabelsNotAllowed() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // can list labels without inheritance
+    gApi.projects().name(project.get()).labels().get();
+
+    // cannot list labels with inheritance
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).labels().withInherited(true).get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("All-Projects: read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void inheritedLabelsOnly() throws Exception {
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review");
+
+    LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
+    LabelAssert.assertCodeReviewLabel(codeReviewLabel);
+  }
+
+  @Test
+  public void withInheritedLabels() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    configLabel("bar", LabelFunction.NO_OP);
+    configLabel("baz", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review", "bar", "baz", "foo").inOrder();
+
+    LabelAssert.assertCodeReviewLabel(labels.get(0));
+    assertThat(labels.get(1).name).isEqualTo("bar");
+    assertThat(labels.get(1).projectName).isEqualTo(project.get());
+    assertThat(labels.get(2).name).isEqualTo("baz");
+    assertThat(labels.get(2).projectName).isEqualTo(project.get());
+    assertThat(labels.get(3).name).isEqualTo("foo");
+    assertThat(labels.get(3).projectName).isEqualTo(project.get());
+  }
+
+  @Test
+  public void withInheritedLabelsAndOverriddenLabel() throws Exception {
+    configLabel("Code-Review", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(project.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review", "Code-Review");
+
+    LabelAssert.assertCodeReviewLabel(labels.get(0));
+    assertThat(labels.get(1).name).isEqualTo("Code-Review");
+    assertThat(labels.get(1).projectName).isEqualTo(project.get());
+    assertThat(labels.get(1).function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
+  }
+
+  @Test
+  public void withInheritedLabelsFromMultipleParents() throws Exception {
+    configLabel(project, "foo", LabelFunction.NO_OP);
+
+    Project.NameKey childProject =
+        projectOperations.newProject().name("child").parent(project).create();
+    configLabel(childProject, "bar", LabelFunction.NO_OP);
+
+    List<LabelDefinitionInfo> labels =
+        gApi.projects().name(childProject.get()).labels().withInherited(true).get();
+    assertThat(labelNames(labels)).containsExactly("Code-Review", "foo", "bar").inOrder();
+
+    LabelAssert.assertCodeReviewLabel(labels.get(0));
+    assertThat(labels.get(1).name).isEqualTo("foo");
+    assertThat(labels.get(1).projectName).isEqualTo(project.get());
+    assertThat(labels.get(2).name).isEqualTo("bar");
+    assertThat(labels.get(2).projectName).isEqualTo(childProject.get());
+  }
+
+  private static List<String> labelNames(List<LabelDefinitionInfo> labels) {
+    return labels.stream().map(l -> l.name).collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 29d3eb2..bb08267 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -26,10 +26,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestProjectInput;
+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.data.Permission;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
new file mode 100644
index 0000000..9e6b051
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -0,0 +1,456 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostLabelsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(new BatchLabelInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(new BatchLabelInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void deleteNonExistingLabel() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void deleteLabels() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isNotEmpty();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo", "Bar");
+    gApi.projects().name(project.get()).labels(input);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void deleteLabels_labelNamesAreTrimmed() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isNotEmpty();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of(" Foo ", " Bar ");
+    gApi.projects().name(project.get()).labels(input);
+    assertThat(gApi.projects().name(project.get()).labels().get()).isEmpty();
+  }
+
+  @Test
+  public void cannotDeleteTheSameLabelTwice() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo", "Foo");
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.name = "Code-Review";
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(labelInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithTheSameName() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, fooInput);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithNamesThatAreTheSameAfterTrim() throws Exception {
+    LabelDefinitionInput foo1Input = new LabelDefinitionInput();
+    foo1Input.name = "Foo";
+    foo1Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput foo2Input = new LabelDefinitionInput();
+    foo2Input.name = " Foo ";
+    foo2Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(foo1Input, foo2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo already exists");
+  }
+
+  @Test
+  public void cannotCreateTwoLabelsWithConflictingNames() throws Exception {
+    LabelDefinitionInput foo1Input = new LabelDefinitionInput();
+    foo1Input.name = "Foo";
+    foo1Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput foo2Input = new LabelDefinitionInput();
+    foo2Input.name = "foo";
+    foo2Input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(foo1Input, foo2Input);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label foo conflicts with existing label Foo");
+  }
+
+  @Test
+  public void createLabels() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput barInput = new LabelDefinitionInput();
+    barInput.name = "Bar";
+    barInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void createLabels_labelNamesAreTrimmed() throws Exception {
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = " Foo ";
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput barInput = new LabelDefinitionInput();
+    barInput.name = " Bar ";
+    barInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooInput, barInput);
+
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo").get()).isNotNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Bar").get()).isNotNull();
+  }
+
+  @Test
+  public void cannotCreateLabelWithoutName() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(new LabelDefinitionInput());
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label name is required for new label");
+  }
+
+  @Test
+  public void cannotSetCommitMessageOnLabelDefinitionInputForCreate() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.name = "Foo";
+    labelInput.commitMessage = "Create Label Foo";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(labelInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message on label definition input not supported");
+  }
+
+  @Test
+  public void updateNonExistingLabel() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Foo", new LabelDefinitionInput());
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void updateLabels() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooUpdate = new LabelDefinitionInput();
+    fooUpdate.function = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    LabelDefinitionInput barUpdate = new LabelDefinitionInput();
+    barUpdate.name = "Baz";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    assertThat(gApi.projects().name(project.get()).label("Foo").get().function)
+        .isEqualTo(fooUpdate.function);
+    assertThat(gApi.projects().name(project.get()).label("Baz").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).label("Bar").get());
+  }
+
+  @Test
+  public void updateLabels_labelNamesAreTrimmed() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+    configLabel("Bar", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooUpdate = new LabelDefinitionInput();
+    fooUpdate.function = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
+    LabelDefinitionInput barUpdate = new LabelDefinitionInput();
+    barUpdate.name = "Baz";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of(" Foo ", fooUpdate, " Bar ", barUpdate);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    assertThat(gApi.projects().name(project.get()).label("Foo").get().function)
+        .isEqualTo(fooUpdate.function);
+    assertThat(gApi.projects().name(project.get()).label("Baz").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).label("Bar").get());
+  }
+
+  @Test
+  public void cannotSetCommitMessageOnLabelDefinitionInputForUpdate() throws Exception {
+    LabelDefinitionInput labelInput = new LabelDefinitionInput();
+    labelInput.commitMessage = "Update label";
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.update = ImmutableMap.of("Code-Review", labelInput);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.projects().name(allProjects.get()).labels(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("commit message on label definition input not supported");
+  }
+
+  @Test
+  public void deleteAndRecreateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.name = "Foo";
+    fooInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooInput.function);
+  }
+
+  @Test
+  public void deleteRecreateAndUpdateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooCreateInput = new LabelDefinitionInput();
+    fooCreateInput.name = "Foo";
+    fooCreateInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooCreateInput.values =
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput fooUpdateInput = new LabelDefinitionInput();
+    fooUpdateInput.function = LabelFunction.ANY_WITH_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.create = ImmutableList.of(fooCreateInput);
+    input.update = ImmutableMap.of("Foo", fooUpdateInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooUpdateInput.function);
+  }
+
+  @Test
+  public void cannotDeleteAndUpdateLabel() throws Exception {
+    configLabel("Foo", LabelFunction.NO_OP);
+
+    LabelDefinitionInput fooInput = new LabelDefinitionInput();
+    fooInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Foo");
+    input.update = ImmutableMap.of("Foo", fooInput);
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () -> gApi.projects().name(project.get()).labels(input));
+    assertThat(thrown).hasMessageThat().contains("label Foo not found");
+  }
+
+  @Test
+  public void createAndUpdateLabel() throws Exception {
+    LabelDefinitionInput fooCreateInput = new LabelDefinitionInput();
+    fooCreateInput.name = "Foo";
+    fooCreateInput.function = LabelFunction.MAX_NO_BLOCK.getFunctionName();
+    fooCreateInput.values =
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+
+    LabelDefinitionInput fooUpdateInput = new LabelDefinitionInput();
+    fooUpdateInput.function = LabelFunction.ANY_WITH_BLOCK.getFunctionName();
+
+    BatchLabelInput input = new BatchLabelInput();
+    input.create = ImmutableList.of(fooCreateInput);
+    input.update = ImmutableMap.of("Foo", fooUpdateInput);
+
+    gApi.projects().name(project.get()).labels(input);
+
+    LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("Foo").get();
+    assertThat(fooLabel.function).isEqualTo(fooUpdateInput.function);
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    gApi.projects().name(allProjects.get()).labels(new BatchLabelInput());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update labels");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.commitMessage = "Batch Update Labels";
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    BatchLabelInput input = new BatchLabelInput();
+    input.commitMessage = " Batch Update Labels ";
+    input.delete = ImmutableList.of("Code-Review");
+    gApi.projects().name(allProjects.get()).labels(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Batch Update Labels");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
new file mode 100644
index 0000000..97b795f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -0,0 +1,895 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@NoHttpd
+public class SetLabelIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void anonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("Code-Review")
+                    .update(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void notAllowed() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(allProjects.get())
+                    .label("Code-Review")
+                    .update(new LabelDefinitionInput()));
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void updateName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "Foo-Review";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.name).isEqualTo(input.name);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+  }
+
+  @Test
+  public void nameIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = " Foo-Review ";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.name).isEqualTo("Foo-Review");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+  }
+
+  @Test
+  public void cannotSetEmptyName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("name cannot be empty");
+  }
+
+  @Test
+  public void cannotSetInvalidName() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "INVALID_NAME";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid name: " + input.name);
+  }
+
+  @Test
+  public void cannotSetNameIfNameClashes() throws Exception {
+    configLabel("Foo-Review", LabelFunction.NO_OP);
+    configLabel("Bar-Review", LabelFunction.NO_OP);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "Bar-Review";
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).label("Foo-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("name " + input.name + " already in use");
+  }
+
+  @Test
+  public void cannotSetNameIfNameConflicts() throws Exception {
+    configLabel("Foo-Review", LabelFunction.NO_OP);
+    configLabel("Bar-Review", LabelFunction.NO_OP);
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = "bar-review";
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.projects().name(project.get()).label("Foo-Review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("name bar-review conflicts with existing label Bar-Review");
+  }
+
+  @Test
+  public void updateFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.function).isEqualTo(input.function);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+        .isEqualTo(input.function);
+  }
+
+  @Test
+  public void functionIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = " " + LabelFunction.NO_OP.getFunctionName() + " ";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+        .isEqualTo(LabelFunction.NO_OP.getFunctionName());
+  }
+
+  @Test
+  public void cannotSetEmptyFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("function cannot be empty");
+  }
+
+  @Test
+  public void cannotSetUnknownFunction() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "UnknownFunction";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
+  }
+
+  @Test
+  public void cannotSetEmptyValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("values cannot be empty");
+  }
+
+  @Test
+  public void updateValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            "2",
+            "Looks Very Good",
+            "+1",
+            "Looks Good",
+            "0",
+            "Don't Know",
+            "-1",
+            "Looks Bad",
+            "-2",
+            "Looks Very Bad");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+  }
+
+  @Test
+  public void valuesAndDescriptionsAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            " 2 ",
+            " Looks Very Good ",
+            " +1 ",
+            " Looks Good ",
+            " 0 ",
+            " Don't Know ",
+            " -1 ",
+            " Looks Bad ",
+            " -2 ",
+            " Looks Very Bad ");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+        .containsExactly(
+            "+2", "Looks Very Good",
+            "+1", "Looks Good",
+            " 0", "Don't Know",
+            "-1", "Looks Bad",
+            "-2", "Looks Very Bad");
+  }
+
+  @Test
+  public void cannotSetInvalidValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("invalidValue", "description");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
+  }
+
+  @Test
+  public void cannotSetValueWithEmptyDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
+  }
+
+  @Test
+  public void cannotSetDuplicateValues() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Positive values can be specified as '<value>' or '+<value>'.
+    input.values =
+        ImmutableMap.of(
+            "+1", "Looks Good", "1", "Looks Good", "0", "Don't Know", "-1", "Looks Bad");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("duplicate value: 1");
+  }
+
+  @Test
+  public void updateDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.defaultValue = 1;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.defaultValue).isEqualTo(input.defaultValue);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().defaultValue)
+        .isEqualTo(input.defaultValue);
+  }
+
+  @Test
+  public void cannotSetInvalidDefaultValue() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.defaultValue = 5;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
+  }
+
+  @Test
+  public void updateBranches() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    // Branches can be full ref, ref pattern or regular expression.
+    input.branches =
+        ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).containsExactlyElementsIn(input.branches);
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactlyElementsIn(input.branches);
+  }
+
+  @Test
+  public void branchesAreTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches =
+        ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches)
+        .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
+  }
+
+  @Test
+  public void emptyBranchesAreIgnored() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("refs/heads/master", "", " ");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).containsExactly("refs/heads/master");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactly("refs/heads/master");
+  }
+
+  @Test
+  public void branchesCanBeUnset() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("refs/heads/master");
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .isNotNull();
+
+    input.branches = ImmutableList.of();
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).isNull();
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .isNull();
+  }
+
+  @Test
+  public void cannotSetInvalidBranch() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("refs heads master");
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+    assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
+  }
+
+  @Test
+  public void branchesAreAutomaticallyPrefixedWithRefsHeads() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.branches = ImmutableList.of("master", "refs/meta/config");
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(updatedLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
+
+    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+        .containsExactly("refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void setCanOverride() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCanOverride(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.canOverride = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.canOverride).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isTrue();
+  }
+
+  @Test
+  public void unsetCanOverride() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.canOverride = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.canOverride).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isNull();
+  }
+
+  @Test
+  public void setCopyAnyScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAnyScore = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAnyScore).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAnyScore(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAnyScore = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAnyScore).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
+  }
+
+  @Test
+  public void setCopyMinScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMinScore = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMinScore).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyMinScore(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMinScore = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMinScore).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
+  }
+
+  @Test
+  public void setCopyMaxScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMaxScore = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMaxScore).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyMaxScore(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyMaxScore = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyMaxScore).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresIfNoChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresIfNoChange(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoChange).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoChange).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
+        .isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresIfNoCodeChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoCodeChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfNoCodeChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
+        .isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresOnTrivialRebase() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnTrivialRebase = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresOnTrivialRebase(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnTrivialRebase = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
+        .isNull();
+  }
+
+  @Test
+  public void setCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnMergeFirstParentUpdate = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresOnMergeFirstParentUpdate = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresOnMergeFirstParentUpdate)
+        .isNull();
+  }
+
+  @Test
+  public void setAllowPostSubmit() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setAllowPostSubmit(false);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.allowPostSubmit = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.allowPostSubmit).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
+  }
+
+  @Test
+  public void unsetAllowPostSubmit() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.allowPostSubmit = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.allowPostSubmit).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
+  }
+
+  @Test
+  public void setIgnoreSelfApproval() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.ignoreSelfApproval).isTrue();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isTrue();
+  }
+
+  @Test
+  public void unsetIgnoreSelfApproval() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType labelType = u.getConfig().getLabelSections().get("foo");
+      labelType.setIgnoreSelfApproval(true);
+      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.ignoreSelfApproval = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.ignoreSelfApproval).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isNull();
+  }
+
+  @Test
+  public void noOpUpdate() throws Exception {
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects()
+            .name(allProjects.get())
+            .label("Code-Review")
+            .update(new LabelDefinitionInput());
+    LabelAssert.assertCodeReviewLabel(updatedLabel);
+
+    LabelAssert.assertCodeReviewLabel(
+        gApi.projects().name(allProjects.get()).label("Code-Review").get());
+
+    assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void defaultCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Update label");
+  }
+
+  @Test
+  public void withCommitMessage() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    input.commitMessage = "Set NoOp function";
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo(input.commitMessage);
+  }
+
+  @Test
+  public void commitMessageIsTrimmed() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = LabelFunction.NO_OP.getFunctionName();
+    input.commitMessage = " Set NoOp function ";
+    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    assertThat(
+            projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+        .isEqualTo("Set NoOp function");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
index 220254b..df6a264 100644
--- a/javatests/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
@@ -19,13 +19,13 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.util.Base64;
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
@@ -50,7 +50,7 @@
                 + FILE_NAME
                 + "/content?parent=1");
     response.assertOK();
-    assertThat(new String(Base64.decode(response.getEntityContent()), UTF_8))
+    assertThat(new String(BaseEncoding.base64().decode(response.getEntityContent()), UTF_8))
         .isEqualTo(parentContent);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 4d1634d..500ab06 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -4,5 +4,8 @@
     srcs = glob(["*IT.java"]),
     group = "server_change",
     labels = ["server"],
-    deps = ["//java/com/google/gerrit/server/util/time"],
+    deps = [
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/util/time",
+    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b1194b1..ba41d7e 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
@@ -83,6 +84,7 @@
   @Inject private Provider<ChangesCollection> changes;
   @Inject private Provider<PostReview> postReview;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject ProjectOperations projectOperations;
 
   private final Integer[] lines = {0, 1};
 
@@ -485,7 +487,7 @@
       ChangeResource changeRsrc =
           changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
       RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
-      postReview.get().apply(batchUpdateFactory, revRsrc, input, timestamp);
+      postReview.get().apply(revRsrc, input, timestamp);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 1e2d1ba..ee5f117 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -313,7 +314,8 @@
             @Override
             public boolean updateChange(ChangeContext ctx) {
               ctx.getChange().setStatus(Change.Status.MERGED);
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+              ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                  .fixStatusToMerged(new SubmissionId(ctx.getChange()));
               return true;
             }
           });
@@ -862,7 +864,8 @@
             @Override
             public boolean updateChange(ChangeContext ctx) {
               ctx.getChange().setStatus(Change.Status.MERGED);
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
+              ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                  .fixStatusToMerged(new SubmissionId(ctx.getChange()));
               return true;
             }
           });
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index e369d1b..b544f6e 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -25,11 +25,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 445f787..a97fb49 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -117,12 +117,12 @@
     // Create two independent commits and push.
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
-    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
 
     testRepo.reset(initialHead);
     RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
 
     if (isSubmitWholeTopicEnabled()) {
       assertSubmittedTogether(id1, id2, id1);
@@ -137,12 +137,12 @@
   public void anonymousWholeTopic() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     RevCommit a = commitBuilder().add("a", "1").message("change 1").create();
-    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("topic"), false);
     String id1 = getChangeId(a);
 
     testRepo.reset(initialHead);
     RevCommit b = commitBuilder().add("b", "1").message("change 2").create();
-    pushHead(testRepo, "refs/for/master/" + name("topic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("topic"), false);
     String id2 = getChangeId(b);
 
     requestScopeOperations.setApiUserAnonymous();
@@ -161,16 +161,16 @@
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
-    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
 
     testRepo.reset(initialHead);
     RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
 
     RevCommit c3_1 = commitBuilder().add("b.txt", "3").message("subject: 3").create();
     String id3 = getChangeId(c3_1);
-    pushHead(testRepo, "refs/for/master/" + name("unrelated-topic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("unrelated-topic"), false);
 
     if (isSubmitWholeTopicEnabled()) {
       assertSubmittedTogether(id1, id2, id1);
@@ -189,16 +189,16 @@
 
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
     String id1 = getChangeId(c1_1);
-    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
 
     testRepo.reset(initialHead);
     RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
     String id2 = getChangeId(c2_1);
-    pushHead(testRepo, "refs/for/master/" + name("otherConnectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("otherConnectingTopic"), false);
 
     RevCommit c3_1 = commitBuilder().add("b.txt", "3").message("subject: 3").create();
     String id3 = getChangeId(c3_1);
-    pushHead(testRepo, "refs/for/master/" + name("connectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
 
     RevCommit c4_1 = commitBuilder().add("b.txt", "4").message("subject: 4").create();
     String id4 = getChangeId(c4_1);
@@ -211,7 +211,7 @@
 
     RevCommit c6_1 = commitBuilder().add("c.txt", "6").message("subject: 6").create();
     String id6 = getChangeId(c6_1);
-    pushHead(testRepo, "refs/for/master/" + name("otherConnectingTopic"), false);
+    pushHead(testRepo, "refs/for/master%topic=" + name("otherConnectingTopic"), false);
 
     if (isSubmitWholeTopicEnabled()) {
       assertSubmittedTogether(id1, id6, id5, id3, id2, id1);
diff --git a/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java b/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java
index 8744cfad..3bc863f 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/EventPayloadIT.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.inject.Inject;
 import org.junit.Test;
@@ -32,32 +32,26 @@
   @Test
   public void defaultOptions() throws Exception {
     RevisionCreatedListener listener =
-        new RevisionCreatedListener() {
-          @Override
-          public void onRevisionCreated(Event event) {
-            assertThat(event.getChange().submittable).isNotNull();
-            assertThat(event.getRevision().files).isNotEmpty();
-          }
+        event -> {
+          assertThat(event.getChange().submittable).isNotNull();
+          assertThat(event.getRevision().files).isNotEmpty();
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+    try (Registration ignored = extensionRegistry.newRegistration().add(listener)) {
       createChange();
     }
   }
 
   @Test
-  @GerritConfig(name = "event.payload.listChangeOptions", value = "SKIP_MERGEABLE")
+  @GerritConfig(name = "event.payload.listChangeOptions", value = "SKIP_DIFFSTAT")
   public void configuredOptions() throws Exception {
     RevisionCreatedListener listener =
-        new RevisionCreatedListener() {
-          @Override
-          public void onRevisionCreated(Event event) {
-            assertThat(event.getChange().submittable).isNull();
-            assertThat(event.getChange().mergeable).isNull();
-            assertThat(event.getRevision().files).isNull();
-            assertThat(event.getChange().subject).isNotEmpty();
-          }
+        event -> {
+          assertThat(event.getChange().submittable).isNull();
+          assertThat(event.getChange().insertions).isNull();
+          assertThat(event.getRevision().files).isNull();
+          assertThat(event.getChange().subject).isNotEmpty();
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+    try (Registration ignored = extensionRegistry.newRegistration().add(listener)) {
       createChange();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 6677583..eb1f907 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.git.receive;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -24,12 +25,13 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.validators.CommentForValidation;
-import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
@@ -46,9 +48,18 @@
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
 
+  private static final int COMMENT_SIZE_LIMIT = 666;
+
   private static final String COMMENT_TEXT = "The comment text";
+  private static final CommentForValidation COMMENT_FOR_VALIDATION =
+      CommentForValidation.create(
+          CommentForValidation.CommentSource.HUMAN,
+          CommentForValidation.CommentType.FILE_COMMENT,
+          COMMENT_TEXT,
+          COMMENT_TEXT.length());
 
   @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> capture;
+  @Captor private ArgumentCaptor<CommentValidationContext> captureCtx;
 
   @Override
   public Module createModule() {
@@ -72,14 +83,14 @@
 
   @Test
   public void validateComments_commentOK() throws Exception {
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(
-                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of());
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     String revId = result.getCommit().getName();
+    when(mockCommentValidator.validateComments(
+            CommentValidationContext.create(
+                result.getChange().getId().get(), result.getChange().project().get()),
+            ImmutableList.of(COMMENT_FOR_VALIDATION)))
+        .thenReturn(ImmutableList.of());
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
     testCommentHelper.addDraft(changeId, revId, comment);
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
@@ -91,16 +102,14 @@
 
   @Test
   public void validateComments_commentRejected() throws Exception {
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentType.FILE_COMMENT, COMMENT_TEXT);
-    when(mockCommentValidator.validateComments(
-            ImmutableList.of(
-                CommentForValidation.create(
-                    CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
-        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     String revId = result.getCommit().getName();
+    when(mockCommentValidator.validateComments(
+            CommentValidationContext.create(
+                result.getChange().getId().get(), result.getChange().project().get()),
+            ImmutableList.of(COMMENT_FOR_VALIDATION)))
+        .thenReturn(ImmutableList.of(COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
     testCommentHelper.addDraft(changeId, revId, comment);
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
@@ -112,7 +121,8 @@
 
   @Test
   public void validateComments_inlineVsFileComments_allOK() throws Exception {
-    when(mockCommentValidator.validateComments(capture.capture())).thenReturn(ImmutableList.of());
+    when(mockCommentValidator.validateComments(captureCtx.capture(), capture.capture()))
+        .thenReturn(ImmutableList.of());
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     String revId = result.getCommit().getName();
@@ -125,12 +135,68 @@
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
     amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(2);
+
     assertThat(capture.getAllValues()).hasSize(1);
-    assertThat(capture.getValue())
+
+    assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
+    assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+
+    assertThat(capture.getAllValues().get(0))
         .containsExactly(
             CommentForValidation.create(
-                CommentForValidation.CommentType.INLINE_COMMENT, draftInline.message),
+                CommentForValidation.CommentSource.HUMAN,
+                CommentForValidation.CommentType.INLINE_COMMENT,
+                draftInline.message,
+                draftInline.message.length()),
             CommentForValidation.create(
-                CommentForValidation.CommentType.FILE_COMMENT, draftFile.message));
+                CommentForValidation.CommentSource.HUMAN,
+                CommentForValidation.CommentType.FILE_COMMENT,
+                draftFile.message,
+                draftFile.message.length()));
+  }
+
+  @Test
+  @GerritConfig(name = "change.commentSizeLimit", value = "" + COMMENT_SIZE_LIMIT)
+  public void validateComments_enforceLimits_commentTooLarge() throws Exception {
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    int commentLength = COMMENT_SIZE_LIMIT + 1;
+    DraftInput comment =
+        testCommentHelper.newDraft(new String(new char[commentLength]).replace("\0", "x"));
+    testCommentHelper.addDraft(changeId, result.getCommit().getName(), comment);
+
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    amendResult.assertOkStatus();
+    amendResult.assertMessage(
+        String.format("Comment size exceeds limit (%d > %d)", commentLength, COMMENT_SIZE_LIMIT));
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "change.maxComments", value = "3")
+  public void countComments_limitNumberOfComments() throws Exception {
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    String filePath = result.getChange().currentFilePaths().get(0);
+    DraftInput draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    for (int i = 1; i < 3; ++i) {
+      testCommentHelper.addRobotComment(
+          changeId,
+          TestCommentHelper.createRobotCommentInput(result.getChange().currentFilePaths().get(0)));
+    }
+
+    draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+    amendResult.assertMessage("exceeding maximum number of comments");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
new file mode 100644
index 0000000..c32827a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.git.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import org.junit.Test;
+
+/** Tests for applying limits to e.g. number of files per change. */
+public class ReceiveCommitsLimitsIT extends AbstractDaemonTest {
+
+  @Inject
+  private @Named("diff_summary") Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
+
+  @Test
+  @GerritConfig(name = "change.maxFiles", value = "1")
+  public void limitFileCount() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "foo",
+                ImmutableMap.of("foo", "foo-1.0", "bar", "bar-1.0"))
+            .to("refs/for/master");
+    result.assertErrorStatus("Exceeding maximum number of files per change (2 > 1)");
+  }
+
+  @Test
+  public void cacheKeyMatches() throws Exception {
+    int cacheSizeBefore = diffSummaryCache.asMap().size();
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "foo",
+                ImmutableMap.of("foo", "foo-1.0", "bar", "bar-1.0"))
+            .to("refs/for/master");
+    result.assertOkStatus();
+
+    // Assert that we only performed the diff computation once. This would e.g. catch
+    // bugs/deviations in the computation of the cache key.
+    assertThat(diffSummaryCache.asMap()).hasSize(cacheSizeBefore + 1);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
index 768c269..3b0e27b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -33,7 +33,9 @@
 public class AbstractMailIT extends AbstractDaemonTest {
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  protected MailMessage.Builder messageBuilderWithDefaultFields() {
+  static final String FILE_NAME = "gerrit-server/test.txt";
+
+  MailMessage.Builder messageBuilderWithDefaultFields() {
     MailMessage.Builder b = MailMessage.builder();
     b.id("some id");
     b.from(user.getEmailAddress());
@@ -43,16 +45,15 @@
     return b;
   }
 
-  protected String createChangeWithReview() throws Exception {
+  String createChangeWithReview() throws Exception {
     return createChangeWithReview(admin);
   }
 
-  protected String createChangeWithReview(TestAccount reviewer) throws Exception {
+  String createChangeWithReview(TestAccount reviewer) throws Exception {
     // Create change
-    String file = "gerrit-server/test.txt";
     String contents = "contents \nlorem \nipsum \nlorem";
     PushOneCommit push =
-        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", FILE_NAME, contents);
     PushOneCommit.Result r = push.to("refs/for/master");
     String changeId = r.getChangeId();
 
@@ -61,8 +62,8 @@
     ReviewInput input = new ReviewInput();
     input.message = "I have two comments";
     input.comments = new HashMap<>();
-    CommentInput c1 = newComment(file, Side.REVISION, 0, "comment on file");
-    CommentInput c2 = newComment(file, Side.REVISION, 2, "inline comment");
+    CommentInput c1 = newComment(FILE_NAME, Side.REVISION, 0, "comment on file");
+    CommentInput c2 = newComment(FILE_NAME, Side.REVISION, 2, "inline comment");
     input.comments.put(c1.path, ImmutableList.of(c1, c2));
     revision(r).review(input);
     return changeId;
@@ -94,7 +95,7 @@
    * @param fc1 Comment in reply to a comment of file 1.
    * @return A string with all inline comments and the original quoted email.
    */
-  protected static String newPlaintextBody(
+  static String newPlaintextBody(
       String changeURL, String changeMessage, String c1, String f1, String fc1) {
     return (changeMessage == null ? "" : changeMessage + "\n")
         + "> Foo Bar has posted comments on this change. (  \n"
@@ -137,7 +138,7 @@
         + "> \n";
   }
 
-  protected static String textFooterForChange(int changeNumber, String timestamp) {
+  static String textFooterForChange(int changeNumber, String timestamp) {
     return "Gerrit-Change-Number: "
         + changeNumber
         + "\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
index d0b7f15d..cc61dfb 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
@@ -19,7 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.inject.Inject;
 import java.io.BufferedReader;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index 13f0416..e03f6fe 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 5531709..502194a 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -21,14 +21,20 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
@@ -70,7 +76,7 @@
   @BeforeClass
   public static void setUpMock() {
     // Let the mock comment validator accept all comments during test setup.
-    when(mockCommentValidator.validateComments(any())).thenReturn(ImmutableList.of());
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
   }
 
   @Before
@@ -274,7 +280,8 @@
         MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
-    setupFailValidation(CommentForValidation.CommentType.CHANGE_MESSAGE);
+    setupFailValidation(
+        CommentForValidation.CommentType.CHANGE_MESSAGE, changeInfo.project, changeInfo._number);
 
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null, null);
@@ -298,7 +305,8 @@
         MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
-    setupFailValidation(CommentForValidation.CommentType.INLINE_COMMENT);
+    setupFailValidation(
+        CommentForValidation.CommentType.INLINE_COMMENT, changeInfo.project, changeInfo._number);
 
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null, null);
@@ -322,7 +330,42 @@
         MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
-    setupFailValidation(CommentForValidation.CommentType.FILE_COMMENT);
+    setupFailValidation(
+        CommentForValidation.CommentType.FILE_COMMENT, changeInfo.project, changeInfo._number);
+
+    MailMessage.Builder b = messageBuilderWithDefaultFields();
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT, null);
+    b.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(b.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
+  @Test
+  @GerritConfig(name = "change.maxComments", value = "4")
+  public void limitNumberOfComments() throws Exception {
+    String changeId = createChangeWithReview();
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = "foo";
+    commentInput.path = FILE_NAME;
+    RobotCommentInput robotCommentInput =
+        TestCommentHelper.createRobotCommentInputWithMandatoryFields(FILE_NAME);
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(commentInput));
+    reviewInput.robotComments = ImmutableMap.of(FILE_NAME, ImmutableList.of(robotCommentInput));
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT, null);
@@ -341,11 +384,15 @@
     return canonicalWebUrl.get() + "c/" + changeInfo.project + "/+/" + changeInfo._number;
   }
 
-  private void setupFailValidation(CommentForValidation.CommentType type) {
-    CommentForValidation commentForValidation = CommentForValidation.create(type, COMMENT_TEXT);
+  private void setupFailValidation(
+      CommentForValidation.CommentType type, String failProject, int failChange) {
+    CommentForValidation commentForValidation =
+        CommentForValidation.create(
+            CommentForValidation.CommentSource.HUMAN, type, COMMENT_TEXT, COMMENT_TEXT.length());
 
     when(mockCommentValidator.validateComments(
-            ImmutableList.of(CommentForValidation.create(type, COMMENT_TEXT))))
+            CommentValidationContext.create(failChange, failProject),
+            ImmutableList.of(commentForValidation)))
         .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index c395c81..0ae9ad2 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.mail.EmailHeader;
 import java.net.URI;
 import java.util.Map;
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index c502c79..3c066a3 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -154,16 +154,20 @@
     AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
 
     String result =
-        retryHelper.execute(
-            batchUpdateFactory -> {
-              try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
-                bu.addOp(
-                    id,
-                    new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
-                bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
-              }
-              return "Done";
-            });
+        retryHelper
+            .changeUpdate(
+                "testUpdateRefAndAddMessageOp",
+                batchUpdateFactory -> {
+                  try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+                    bu.addOp(
+                        id,
+                        new UpdateRefAndAddMessageOp(
+                            updateRepoCalledCount, updateChangeCalledCount));
+                    bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+                  }
+                  return "Done";
+                })
+            .call();
 
     assertThat(result).isEqualTo("Done");
     assertThat(updateRepoCalledCount.get()).isEqualTo(2);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 4fe0df4..f866fff 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -22,10 +22,10 @@
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 4e9c4a4..9b8b19c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -67,6 +67,7 @@
           "receive-pack",
           "rename-group",
           "review",
+          "sequence",
           "set-account",
           "set-head",
           "set-members",
@@ -96,6 +97,7 @@
               "gerrit plugin",
               ImmutableList.of("add", "enable", "install", "ls", "reload", "remove", "rm"))
           .put("gerrit test-submit", ImmutableList.of("rule", "type"))
+          .put("gerrit sequence", ImmutableList.of("set", "show"))
           .build();
 
   private static final ImmutableMap<String, List<String>> SLAVE_COMMANDS =
diff --git a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
index 34406e0..f1db016 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/UploadArchiveIT.java
@@ -19,11 +19,11 @@
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
diff --git a/javatests/com/google/gerrit/entities/AccountGroupTest.java b/javatests/com/google/gerrit/entities/AccountGroupTest.java
index a9d5188..e0a9154 100644
--- a/javatests/com/google/gerrit/entities/AccountGroupTest.java
+++ b/javatests/com/google/gerrit/entities/AccountGroupTest.java
@@ -19,11 +19,6 @@
 import static com.google.gerrit.entities.AccountGroup.UUID.fromRefPart;
 import static com.google.gerrit.entities.AccountGroup.uuid;
 
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.LocalDateTime;
-import java.time.Month;
-import java.time.ZoneOffset;
 import org.junit.Test;
 
 public class AccountGroupTest {
@@ -31,12 +26,6 @@
   private static final String TEST_SHARDED_UUID = TEST_UUID.substring(0, 2) + "/" + TEST_UUID;
 
   @Test
-  public void auditCreationInstant() {
-    Instant instant = LocalDateTime.of(2009, Month.JUNE, 8, 19, 31).toInstant(ZoneOffset.UTC);
-    assertThat(AccountGroup.auditCreationInstantTs()).isEqualTo(Timestamp.from(instant));
-  }
-
-  @Test
   public void parseRefName() {
     assertThat(fromRef("refs/groups/" + TEST_SHARDED_UUID)).isEqualTo(uuid(TEST_UUID));
     assertThat(fromRef("refs/groups/" + TEST_SHARDED_UUID + "-2"))
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 72e4a7a..bc669cc 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -335,6 +335,7 @@
                 .put("workInProgress", boolean.class)
                 .put("reviewStarted", boolean.class)
                 .put("revertOf", Change.Id.class)
+                .put("cherryPickOf", PatchSet.Id.class)
                 .build());
   }
 
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 15c66eb..d9438f0 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -20,6 +20,7 @@
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
 import java.util.HashMap;
+import java.util.Map;
 import org.junit.Test;
 
 public class IndexHtmlUtilTest {
@@ -70,6 +71,17 @@
             "canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
   }
 
+  @Test
+  public void useGoogleFonts() throws Exception {
+    Map<String, String[]> urlParms = new HashMap<>();
+    urlParms.put("gf", new String[0]);
+    assertThat(
+            staticTemplateData(
+                "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain))
+        .containsExactly(
+            "canonicalPath", "", "staticResourcePath", ordain(""), "useGoogleFonts", "true");
+  }
+
   private static SanitizedContent ordain(String s) {
     return UnsafeSanitizedContentOrdainer.ordainAsSafe(
         s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
diff --git a/javatests/com/google/gerrit/index/query/RangeUtilTest.java b/javatests/com/google/gerrit/index/query/RangeUtilTest.java
new file mode 100644
index 0000000..681f9d99b
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/RangeUtilTest.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.query.RangeUtil.Range;
+import org.junit.Test;
+
+public class RangeUtilTest {
+  @Test
+  public void getRangeForValueOutsideOfMinMaxRange_minNotGreaterThanMax() {
+    for (String operator : ImmutableList.of("=", ">", ">=", "<", "<=")) {
+      Range range = RangeUtil.getRange("foo", operator, 10, -4, 4);
+      assertThat(range.min).isAtMost(range.max);
+
+      range = RangeUtil.getRange("foo", operator, -10, -4, 4);
+      assertThat(range.min).isAtMost(range.max);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/mail/BUILD b/javatests/com/google/gerrit/mail/BUILD
index bd2c478..3d63844 100644
--- a/javatests/com/google/gerrit/mail/BUILD
+++ b/javatests/com/google/gerrit/mail/BUILD
@@ -15,7 +15,6 @@
         "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-junit",
-        "//lib/commons:codec",
         "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index 49371f4..b68aacc 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -5,9 +5,7 @@
     name = "pgm_tests",
     srcs = glob(["**/*.java"]),
     deps = [
-        "//java/com/google/gerrit/pgm",
         "//java/com/google/gerrit/pgm/http/jetty",
-        "//java/com/google/gerrit/pgm/init",
         "//java/com/google/gerrit/pgm/init/api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/securestore/testing",
diff --git a/javatests/com/google/gerrit/prettify/BUILD b/javatests/com/google/gerrit/prettify/BUILD
new file mode 100644
index 0000000..0eb7cee
--- /dev/null
+++ b/javatests/com/google/gerrit/prettify/BUILD
@@ -0,0 +1,13 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "prettify_tests",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/prettify:server",
+        "//java/com/google/gerrit/prettify/common/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/prettify/common/SparseFileContentBuilderTest.java b/javatests/com/google/gerrit/prettify/common/SparseFileContentBuilderTest.java
new file mode 100644
index 0000000..a751d50
--- /dev/null
+++ b/javatests/com/google/gerrit/prettify/common/SparseFileContentBuilderTest.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.prettify.common;
+
+import static com.google.gerrit.prettify.common.testing.SparseFileContentSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.Ignore;
+import org.junit.Test;
+
+public class SparseFileContentBuilderTest {
+
+  @Test
+  public void addLineWithNegativeNumber() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(10);
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(-1, "First line"));
+
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(-5, "First line"));
+  }
+
+  @Test
+  @Ignore
+  public void addLineNumberZeroFileSize() {
+    // Temporary ignore - see comments in SparseFileContentBuilder.build() method
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(0);
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(0, "First line"));
+  }
+
+  @Test
+  @Ignore
+  public void addLineNumberNonZeroFileSize() {
+    // Temporary ignore - see comments in SparseFileContentBuilder.build() method
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(5);
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(5, "First line"));
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(6, "First line"));
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(7, "First line"));
+  }
+
+  @Test
+  public void addLineIncorrectOrder() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(5);
+
+    builder.addLine(0, "First line");
+    builder.addLine(1, "Second line");
+    builder.addLine(3, "Third line");
+    builder.addLine(4, "Fourth line");
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(4, "Other Line"));
+
+    assertThrows(IllegalArgumentException.class, () -> builder.addLine(2, "Other Line"));
+  }
+
+  @Test
+  public void emptyContentZeroSize() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(0);
+
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(0);
+    assertThat(content).getRangesCount().isEqualTo(0);
+    assertThat(content).lines().isEmpty();
+  }
+
+  @Test
+  public void emptyContentNonZeroSize() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(4);
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(4);
+    assertThat(content).getRangesCount().isEqualTo(0);
+    assertThat(content).lines().isEmpty();
+  }
+
+  @Test
+  public void oneLineContentLineNumberZero() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(1);
+
+    builder.addLine(0, "First line");
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(1);
+    assertThat(content).getRangesCount().isEqualTo(1);
+    assertThat(content).lines().containsExactlyEntriesIn(ImmutableMap.of(0, "First line"));
+  }
+
+  @Test
+  public void oneLineContentLineNumberNotZero() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(6);
+
+    builder.addLine(5, "First line");
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(6);
+    assertThat(content).getRangesCount().isEqualTo(1);
+    assertThat(content).lines().containsExactlyEntriesIn(ImmutableMap.of(5, "First line"));
+  }
+
+  @Test
+  public void multiLineContinuousContentStartingFromZero() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(5);
+
+    builder.addLine(0, "First line");
+    builder.addLine(1, "Second line");
+    builder.addLine(2, "Third line");
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(5);
+    assertThat(content).getRangesCount().isEqualTo(1);
+    assertThat(content)
+        .lines()
+        .containsExactlyEntriesIn(
+            ImmutableMap.of(
+                0, "First line",
+                1, "Second line",
+                2, "Third line"));
+  }
+
+  @Test
+  public void multiLineContentStartingFromNonZeroLine() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(8);
+
+    builder.addLine(5, "First line");
+    builder.addLine(6, "Second line");
+    builder.addLine(7, "Third line");
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(8);
+    assertThat(content).getRangesCount().isEqualTo(1);
+    assertThat(content)
+        .lines()
+        .containsExactlyEntriesIn(
+            ImmutableMap.of(
+                5, "First line",
+                6, "Second line",
+                7, "Third line"));
+  }
+
+  @Test
+  public void multiLineContentWithGaps() {
+    SparseFileContentBuilder builder = new SparseFileContentBuilder(10000);
+    builder.addLine(0, "First line");
+    builder.addLine(1, "Second line");
+    builder.addLine(3, "Third line");
+    builder.addLine(4, "Fourth line");
+    builder.addLine(5, "Fifth line");
+    builder.addLine(6, "Sixth line");
+    builder.addLine(10, "Seventh line");
+    SparseFileContent content = builder.build();
+    assertThat(content).getSize().isEqualTo(10000);
+    assertThat(content).getRangesCount().isEqualTo(3);
+    assertThat(content)
+        .lines()
+        .containsExactlyEntriesIn(
+            ImmutableMap.builder()
+                .put(0, "First line")
+                .put(1, "Second line")
+                .put(3, "Third line")
+                .put(4, "Fourth line")
+                .put(5, "Fifth line")
+                .put(6, "Sixth line")
+                .put(10, "Seventh line")
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index b94a709..022813b 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -55,6 +55,8 @@
         "//java/com/google/gerrit/server/account/externalids/testing",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/server/fixes/testing",
+        "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
@@ -73,7 +75,6 @@
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:codec",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/mockito",
diff --git a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java b/javatests/com/google/gerrit/server/account/GroupUuidTest.java
similarity index 86%
rename from javatests/com/google/gerrit/server/account/GroupUUIDTest.java
rename to javatests/com/google/gerrit/server/account/GroupUuidTest.java
index a155d7f..fbf3374 100644
--- a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
+++ b/javatests/com/google/gerrit/server/account/GroupUuidTest.java
@@ -20,13 +20,13 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 
-public class GroupUUIDTest {
+public class GroupUuidTest {
   @Test
   public void createdUuidsForSameInputShouldBeDifferent() {
     String groupName = "Users";
     PersonIdent personIdent = new PersonIdent("John", "john@example.com");
-    AccountGroup.UUID uuid1 = GroupUUID.make(groupName, personIdent);
-    AccountGroup.UUID uuid2 = GroupUUID.make(groupName, personIdent);
+    AccountGroup.UUID uuid1 = GroupUuid.make(groupName, personIdent);
+    AccountGroup.UUID uuid2 = GroupUuid.make(groupName, personIdent);
     assertThat(uuid2).isNotEqualTo(uuid1);
   }
 }
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 054b1aa..2f64ed0 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -18,9 +18,9 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyZeroInteractions;
-import static org.mockito.Mockito.when;
 
 import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
@@ -44,7 +44,6 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.mockito.Mock;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
@@ -52,8 +51,7 @@
 public class ExternalIDCacheLoaderTest {
   private static AllUsersName ALL_USERS = new AllUsersName(AllUsersNameProvider.DEFAULT);
 
-  @Mock Cache<ObjectId, AllExternalIds> externalIdCache;
-
+  private Cache<ObjectId, AllExternalIds> externalIdCache;
   private ExternalIdCacheLoader loader;
   private GitRepositoryManager repoManager = new InMemoryRepositoryManager();
   private ExternalIdReader externalIdReader;
@@ -61,6 +59,7 @@
 
   @Before
   public void setUp() throws Exception {
+    externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
     externalIdReader = new ExternalIdReader(repoManager, ALL_USERS, new DisabledMetricMaker());
     externalIdReaderSpy = Mockito.spy(externalIdReader);
@@ -78,8 +77,7 @@
   public void reloadsSingleUpdateUsingPartialReload() throws Exception {
     ObjectId firstState = insertExternalId(1, 1);
     ObjectId head = insertExternalId(2, 2);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -91,8 +89,7 @@
     insertExternalId(2, 2);
     insertExternalId(3, 3);
     ObjectId head = insertExternalId(4, 4);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -100,11 +97,9 @@
 
   @Test
   public void reloadsAllExternalIdsWhenNoOldStateIsCached() throws Exception {
-    ObjectId firstState = insertExternalId(1, 1);
+    insertExternalId(1, 1);
     ObjectId head = insertExternalId(2, 2);
 
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(null);
-
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verify(externalIdReaderSpy, times(1)).all(head);
   }
@@ -144,8 +139,7 @@
     ObjectId firstState = insertExternalId(1, 1);
     ObjectId head = deleteExternalId(1, 1);
     assertThat(allFromGit(head).byAccount().size()).isEqualTo(0);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -159,8 +153,7 @@
             externalId(1, 1),
             ExternalId.create("fooschema", "bar1", Account.id(1), "foo@bar.com", "password"));
     assertThat(allFromGit(head).byAccount().size()).isEqualTo(1);
-
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -177,7 +170,7 @@
       head = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
     }
 
-    when(externalIdCache.getIfPresent(firstState)).thenReturn(allFromGit(firstState));
+    externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -190,8 +183,7 @@
     ObjectId oldState = inserExternalIds(257);
     assertAllFilesHaveSlashesInPath();
     ObjectId head = insertExternalId(500, 500);
-
-    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+    externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
@@ -205,8 +197,7 @@
     // Create one more external ID and then have the Loader compute the new state
     ObjectId head = insertExternalId(500, 500);
     assertAllFilesHaveSlashesInPath(); // NoteMap resharded
-
-    when(externalIdCache.getIfPresent(oldState)).thenReturn(allFromGit(oldState));
+    externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
     verifyZeroInteractions(externalIdReaderSpy);
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 2485613..de23ef4 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import java.util.Collection;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -85,7 +84,7 @@
     }
 
     @Override
-    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+    public Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
       throw new UnsupportedOperationException("not implemented");
     }
diff --git a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java b/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
deleted file mode 100644
index ba80c02..0000000
--- a/javatests/com/google/gerrit/server/fixes/LineIdentifierTest.java
+++ /dev/null
@@ -1,255 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.fixes;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import org.junit.Test;
-
-public class LineIdentifierTest {
-  @Test
-  public void lineNumberMustBePositive() {
-    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    StringIndexOutOfBoundsException thrown =
-        assertThrows(
-            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(0));
-    assertThat(thrown).hasMessageThat().contains("positive");
-  }
-
-  @Test
-  public void lineNumberMustIndicateAnAvailableLine() {
-    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
-    StringIndexOutOfBoundsException thrown =
-        assertThrows(
-            StringIndexOutOfBoundsException.class, () -> lineIdentifier.getStartIndexOfLine(3));
-    assertThat(thrown).hasMessageThat().contains("Line 3 isn't available");
-  }
-
-  @Test
-  public void startIndexOfFirstLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
-    int startIndex = lineIdentifier.getStartIndexOfLine(1);
-    assertThat(startIndex).isEqualTo(0);
-  }
-
-  @Test
-  public void lengthOfFirstLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
-    int lineLength = lineIdentifier.getLengthOfLine(1);
-    assertThat(lineLength).isEqualTo(8);
-  }
-
-  @Test
-  public void startIndexOfSecondLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(9);
-  }
-
-  @Test
-  public void lengthOfSecondLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
-    int lineLength = lineIdentifier.getLengthOfLine(2);
-    assertThat(lineLength).isEqualTo(3);
-  }
-
-  @Test
-  public void startIndexOfLastLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
-    int startIndex = lineIdentifier.getStartIndexOfLine(3);
-    assertThat(startIndex).isEqualTo(13);
-  }
-
-  @Test
-  public void lengthOfLastLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
-    int lineLength = lineIdentifier.getLengthOfLine(3);
-    assertThat(lineLength).isEqualTo(7);
-  }
-
-  @Test
-  public void emptyFirstLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
-    int startIndex = lineIdentifier.getStartIndexOfLine(1);
-    assertThat(startIndex).isEqualTo(0);
-  }
-
-  @Test
-  public void lengthOfEmptyFirstLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
-    int lineLength = lineIdentifier.getLengthOfLine(1);
-    assertThat(lineLength).isEqualTo(0);
-  }
-
-  @Test
-  public void emptyIntermediaryLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(9);
-  }
-
-  @Test
-  public void lengthOfEmptyIntermediaryLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
-    int lineLength = lineIdentifier.getLengthOfLine(2);
-    assertThat(lineLength).isEqualTo(0);
-  }
-
-  @Test
-  public void lineAfterIntermediaryLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
-    int startIndex = lineIdentifier.getStartIndexOfLine(3);
-    assertThat(startIndex).isEqualTo(10);
-  }
-
-  @Test
-  public void emptyLastLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
-    int startIndex = lineIdentifier.getStartIndexOfLine(3);
-    assertThat(startIndex).isEqualTo(13);
-  }
-
-  @Test
-  public void lengthOfEmptyLastLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
-    int lineLength = lineIdentifier.getLengthOfLine(3);
-    assertThat(lineLength).isEqualTo(0);
-  }
-
-  @Test
-  public void startIndexOfSingleLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
-    int startIndex = lineIdentifier.getStartIndexOfLine(1);
-    assertThat(startIndex).isEqualTo(0);
-  }
-
-  @Test
-  public void lengthOfSingleLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
-    int lineLength = lineIdentifier.getLengthOfLine(1);
-    assertThat(lineLength).isEqualTo(8);
-  }
-
-  @Test
-  public void startIndexOfSingleEmptyLineIsRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("");
-    int startIndex = lineIdentifier.getStartIndexOfLine(1);
-    assertThat(startIndex).isEqualTo(0);
-  }
-
-  @Test
-  public void lengthOfSingleEmptyLineIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("");
-    int lineLength = lineIdentifier.getLengthOfLine(1);
-    assertThat(lineLength).isEqualTo(0);
-  }
-
-  @Test
-  public void lookingUpSubsequentLinesIsPossible() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
-
-    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
-    assertThat(firstLineStartIndex).isEqualTo(0);
-
-    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(secondLineStartIndex).isEqualTo(9);
-  }
-
-  @Test
-  public void lookingUpNotSubsequentLinesInAscendingOrderIsPossible() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
-
-    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
-    assertThat(firstLineStartIndex).isEqualTo(0);
-
-    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
-    assertThat(fourthLineStartIndex).isEqualTo(21);
-  }
-
-  @Test
-  public void lookingUpNotSubsequentLinesInDescendingOrderIsPossible() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
-
-    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
-    assertThat(fourthLineStartIndex).isEqualTo(21);
-
-    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(secondLineStartIndex).isEqualTo(9);
-  }
-
-  @Test
-  public void linesSeparatedByOnlyCarriageReturnAreRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(9);
-  }
-
-  @Test
-  public void lengthOfLinesSeparatedByOnlyCarriageReturnIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
-    int lineLength = lineIdentifier.getLengthOfLine(2);
-    assertThat(lineLength).isEqualTo(3);
-  }
-
-  @Test
-  public void linesSeparatedByLineFeedAndCarriageReturnAreRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(10);
-  }
-
-  @Test
-  public void lengthOfLinesSeparatedByLineFeedAndCarriageReturnIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
-    int lineLength = lineIdentifier.getLengthOfLine(2);
-    assertThat(lineLength).isEqualTo(3);
-  }
-
-  @Test
-  public void linesSeparatedByMixtureOfCarriageReturnAndLineFeedAreRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r\n12\n123456\r\n1234");
-    int startIndex = lineIdentifier.getStartIndexOfLine(5);
-    assertThat(startIndex).isEqualTo(25);
-  }
-
-  @Test
-  public void linesSeparatedBySomeUnicodeLinebreakCharacterAreRecognized() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(9);
-  }
-
-  @Test
-  public void lengthOfLinesSeparatedBySomeUnicodeLinebreakCharacterIsCorrect() {
-    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
-    int lineLength = lineIdentifier.getLengthOfLine(2);
-    assertThat(lineLength).isEqualTo(3);
-  }
-
-  @Test
-  public void blanksAreNotInterpretedAsLineSeparators() {
-    LineIdentifier lineIdentifier = new LineIdentifier("1 2345678\n123\n12");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(10);
-  }
-
-  @Test
-  public void tabsAreNotInterpretedAsLineSeparators() {
-    LineIdentifier lineIdentifier = new LineIdentifier("123\t45678\n123\n12");
-    int startIndex = lineIdentifier.getStartIndexOfLine(2);
-    assertThat(startIndex).isEqualTo(10);
-  }
-}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java
new file mode 100644
index 0000000..51fbc67
--- /dev/null
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.fixCalculator;
+
+import static com.google.gerrit.server.fixes.testing.FixResultSubject.assertThat;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.assertThat;
+
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import org.eclipse.jgit.diff.Edit;
+import org.junit.Test;
+
+public class EmptyContentTest {
+  @Test
+  public void insertSingleLineNoEOL() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abc");
+    assertThat(fixResult).edits().onlyElement();
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineWithEOL() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\n");
+    assertThat(fixResult).edits().onlyElement();
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertMultilineNoEOL() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc\nDEFGH");
+    assertThat(fixResult).text().isEqualTo("Abc\nDEFGH");
+    assertThat(fixResult).edits().onlyElement();
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 9);
+  }
+
+  @Test
+  public void insertMultilineWithEOL() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc\nDEFGH\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDEFGH\n");
+    assertThat(fixResult).edits().onlyElement();
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
new file mode 100644
index 0000000..861af3e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.fixCalculator;
+
+import static com.google.gerrit.server.fixes.testing.FixResultSubject.assertThat;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.fixes.FixCalculator;
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import com.google.gerrit.server.patch.Text;
+import org.eclipse.jgit.diff.Edit;
+import org.junit.Test;
+
+public class FixCalculatorVariousTest {
+  private static final String multilineContentString =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\n";
+  private static final Text multilineContent = new Text(multilineContentString.getBytes(UTF_8));
+
+  public static FixResult calculateFixSingleReplacement(
+      String content, int startLine, int startChar, int endLine, int endChar, String replacement)
+      throws ResourceConflictException {
+    FixReplacement fixReplacement =
+        new FixReplacement(
+            "AnyPath", new Range(startLine, startChar, endLine, endChar), replacement);
+    return FixCalculator.calculateFix(
+        new Text(content.getBytes(UTF_8)), ImmutableList.of(fixReplacement));
+  }
+
+  @Test
+  public void lineNumberMustBePositive() {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\nSecond line", 0, 0, 0, 0, "Abc"));
+  }
+
+  @Test
+  public void insertAtTheEndOfSingleLineContentHasEOLMarkInvalidPosition() throws Exception {
+    assertThrows(
+        ResourceConflictException.class,
+        () -> calculateFixSingleReplacement("First line\n", 1, 11, 1, 11, "Abc"));
+  }
+
+  @Test
+  public void severalChangesInTheSameLineNonSorted() throws Exception {
+    FixReplacement replace = new FixReplacement("path", new Range(2, 1, 2, 3), "ABC");
+    FixReplacement insert = new FixReplacement("path", new Range(2, 5, 2, 5), "DEFG");
+    FixReplacement delete = new FixReplacement("path", new Range(2, 7, 2, 9), "");
+    FixResult result =
+        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, delete, insert));
+    assertThat(result)
+        .text()
+        .isEqualTo("First line\nSABConDEFGd ne\nThird line\nFourth line\nFifth line\n");
+    assertThat(result).edits().hasSize(1);
+    Edit edit = result.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 1);
+    assertThat(edit).internalEdits().hasSize(3);
+    assertThat(edit).internalEdits().element(0).isReplace(1, 2, 1, 3);
+    assertThat(edit).internalEdits().element(1).isInsert(5, 6, 4);
+    assertThat(edit).internalEdits().element(2).isDelete(7, 2, 12);
+  }
+
+  @Test
+  public void severalChangesInConsecutiveLines() throws Exception {
+    FixReplacement replace = new FixReplacement("path", new Range(2, 1, 2, 3), "ABC");
+    FixReplacement insert = new FixReplacement("path", new Range(3, 5, 3, 5), "DEFG");
+    FixReplacement delete = new FixReplacement("path", new Range(4, 7, 4, 9), "");
+    FixResult result =
+        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, insert, delete));
+    assertThat(result)
+        .text()
+        .isEqualTo("First line\nSABCond line\nThirdDEFG line\nFourth ne\nFifth line\n");
+    assertThat(result).edits().hasSize(1);
+    Edit edit = result.edits.get(0);
+    assertThat(edit).isReplace(1, 3, 1, 3);
+    assertThat(edit).internalEdits().hasSize(3);
+    assertThat(edit).internalEdits().element(0).isReplace(1, 2, 1, 3);
+    assertThat(edit).internalEdits().element(1).isInsert(17, 18, 4);
+    assertThat(edit).internalEdits().element(2).isDelete(30, 2, 35);
+  }
+
+  @Test
+  public void severalChangesInNonConsecutiveLines() throws Exception {
+    FixReplacement replace = new FixReplacement("path", new Range(1, 1, 1, 3), "ABC");
+    FixReplacement insert = new FixReplacement("path", new Range(3, 5, 3, 5), "DEFG");
+    FixReplacement delete = new FixReplacement("path", new Range(5, 9, 6, 0), "");
+    FixResult result =
+        FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, insert, delete));
+    assertThat(result)
+        .text()
+        .isEqualTo("FABCst line\nSecond line\nThirdDEFG line\nFourth line\nFifth lin");
+    assertThat(result).edits().hasSize(3);
+    assertThat(result).edits().element(0).isReplace(0, 1, 0, 1);
+    assertThat(result).edits().element(0).internalEdits().onlyElement().isReplace(1, 2, 1, 3);
+    assertThat(result).edits().element(1).isReplace(2, 1, 2, 1);
+    assertThat(result).edits().element(1).internalEdits().onlyElement().isInsert(5, 5, 4);
+    assertThat(result).edits().element(2).isReplace(4, 1, 4, 1);
+    assertThat(result).edits().element(2).internalEdits().onlyElement().isDelete(9, 2, 9);
+  }
+
+  @Test
+  public void multipleChanges() throws Exception {
+    String str =
+        "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+            + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+    Text content = new Text(str.getBytes(UTF_8));
+
+    FixReplacement multiLineReplace =
+        new FixReplacement("path", new Range(1, 2, 3, 3), "AB\nC\nDEFG\nQ\n");
+    FixReplacement multiLineDelete = new FixReplacement("path", new Range(4, 8, 5, 8), "");
+    FixReplacement singleLineInsert = new FixReplacement("path", new Range(5, 10, 5, 10), "QWERTY");
+
+    FixReplacement singleLineReplace = new FixReplacement("path", new Range(7, 3, 7, 7), "XY");
+    FixReplacement multiLineInsert =
+        new FixReplacement("path", new Range(8, 7, 8, 7), "KLMNO\nASDF");
+
+    FixReplacement singleLineDelete = new FixReplacement("path", new Range(10, 3, 10, 7), "");
+
+    FixResult result =
+        FixCalculator.calculateFix(
+            content,
+            ImmutableList.of(
+                multiLineReplace,
+                multiLineDelete,
+                singleLineInsert,
+                singleLineReplace,
+                multiLineInsert,
+                singleLineDelete));
+    assertThat(result)
+        .text()
+        .isEqualTo(
+            "FiAB\nC\nDEFG\nQ\nrd line\nFourth lneQWERTY\nSixth line\nSevXY line\nEighth KLMNO\nASDFline\nNinth line\nTenine\n");
+    assertThat(result).edits().hasSize(3);
+    assertThat(result).edits().element(0).isReplace(0, 5, 0, 6);
+    assertThat(result)
+        .edits()
+        .element(0)
+        .internalEdits()
+        .containsExactly(
+            new Edit(2, 26, 2, 14), new Edit(42, 54, 30, 30), new Edit(56, 56, 32, 38));
+
+    assertThat(result).edits().element(1).isReplace(6, 2, 7, 3);
+    assertThat(result)
+        .edits()
+        .element(1)
+        .internalEdits()
+        .containsExactly(new Edit(3, 7, 3, 5), new Edit(20, 20, 18, 28));
+    assertThat(result).edits().element(2).isReplace(9, 1, 11, 1);
+    assertThat(result).edits().element(2).internalEdits().onlyElement().isDelete(3, 4, 3);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java
new file mode 100644
index 0000000..dd36e3a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentNoEOLTest.java
@@ -0,0 +1,337 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.fixCalculator;
+
+import static com.google.gerrit.server.fixes.testing.FixResultSubject.assertThat;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.assertThat;
+
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import org.eclipse.jgit.diff.Edit;
+import org.junit.Test;
+
+public class MultilineContentNoEOLTest {
+
+  @Test
+  public void insertSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("AbcFirst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 5, 2, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSeconAbcd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 10, 3, 10, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird lineAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(10, 10, 3);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nFirst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 5, 2, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSeconAbc\nd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 10, 3, 10, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird lineAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(10, 10, 4);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nFirst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 5, 2, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSeconAbc\nDefgh\nd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 3);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 10, 3, 10, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird lineAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(10, 10, 10);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 2, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abcrst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 3, 2, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbcd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 8, 3, 10, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird liAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 2, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nrst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 3, 2, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbc\nd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 8, 3, 10, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird liAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 4);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 2, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nrst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 3, 2, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbc\nDefgh\nd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 8, 3, 10, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird liAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 2, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefghrst line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 2, 3, 2, 5, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbc\nDefghd line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 8, 3, 10, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird liAbc\nDefgh");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 9);
+  }
+
+  @Test
+  public void replaceWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 3, 10, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 3, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 33, 0, 3);
+  }
+
+  @Test
+  public void deleteWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 3, 10, "");
+    assertThat(fixResult).text().isEqualTo("");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isDelete(0, 3, 0);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 33, 0);
+  }
+
+  @Test
+  public void deleteAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 0, 1, 4, "");
+    assertThat(fixResult).text().isEqualTo("t line\nSecond line\nThird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 4, 0);
+  }
+
+  @Test
+  public void deleteInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 1, 5, 3, 1, "");
+    assertThat(fixResult).text().isEqualTo("Firsthird line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 3, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(5, 19, 5);
+  }
+
+  @Test
+  public void deleteAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line", 3, 7, 3, 10, "");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird l");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(7, 3, 7);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentWithEOLTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentWithEOLTest.java
new file mode 100644
index 0000000..a2868c8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/MultilineContentWithEOLTest.java
@@ -0,0 +1,337 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.fixCalculator;
+
+import static com.google.gerrit.server.fixes.testing.FixResultSubject.assertThat;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.assertThat;
+
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import org.eclipse.jgit.diff.Edit;
+import org.junit.Test;
+
+public class MultilineContentWithEOLTest {
+
+  @Test
+  public void insertSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("AbcFirst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 5, 2, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSeconAbcd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 4, 0, 4, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird line\nAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(3, 3, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nFirst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 5, 2, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSeconAbc\nd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 4, 0, 4, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird line\nAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(3, 3, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nFirst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 5, 2, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSeconAbc\nDefgh\nd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 3);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 4, 0, 4, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird line\nAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(3, 3, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 2, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abcrst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 3, 2, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbcd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 3, 9, 4, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird linAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(9, 2, 9, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 2, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nrst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 3, 2, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbc\nd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 3, 9, 4, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird linAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(9, 2, 9, 4);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 2, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nrst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 3, 2, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbc\nDefgh\nd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 3, 9, 4, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird linAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(9, 2, 9, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 2, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefghrst line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 2, 3, 2, 5, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("First line\nSecAbc\nDefghd line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(1, 1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 3, 9, 4, 0, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird linAbc\nDefgh");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(9, 2, 9, 9);
+  }
+
+  @Test
+  public void replaceWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 4, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 3, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 34, 0, 3);
+  }
+
+  @Test
+  public void deleteWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 4, 0, "");
+    assertThat(fixResult).text().isEqualTo("");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isDelete(0, 3, 0);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 34, 0);
+  }
+
+  @Test
+  public void deleteAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 0, 1, 4, "");
+    assertThat(fixResult).text().isEqualTo("t line\nSecond line\nThird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 4, 0);
+  }
+
+  @Test
+  public void deleteInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 1, 5, 3, 1, "");
+    assertThat(fixResult).text().isEqualTo("Firsthird line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 3, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(5, 19, 5);
+  }
+
+  @Test
+  public void deleteAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\nSecond line\nThird line\n", 3, 7, 4, 0, "");
+    assertThat(fixResult).text().isEqualTo("First line\nSecond line\nThird l");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(2, 1, 2, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(7, 4, 7);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/OneLineContentNoEOLTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/OneLineContentNoEOLTest.java
new file mode 100644
index 0000000..3de4ef7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/OneLineContentNoEOLTest.java
@@ -0,0 +1,320 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.fixCalculator;
+
+import static com.google.gerrit.server.fixes.testing.FixResultSubject.assertThat;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.assertThat;
+
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import org.eclipse.jgit.diff.Edit;
+import org.junit.Test;
+
+public class OneLineContentNoEOLTest {
+
+  @Test
+  public void insertSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("AbcFirst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 5, 1, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("FirstAbc line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 10, 1, 10, "Abc");
+    assertThat(fixResult).text().isEqualTo("First lineAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(10, 10, 3);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nFirst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 5, 1, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("FirstAbc\n line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 10, 1, 10, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First lineAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(10, 10, 4);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 0, 1, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nFirst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 5, 1, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("FirstAbc\nDefgh\n line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 10, 1, 10, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First lineAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(10, 10, 10);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 2, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abcrst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 3, 1, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("FirAbc line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 8, 1, 10, "Abc");
+    assertThat(fixResult).text().isEqualTo("First liAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 2, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nrst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 3, 1, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("FirAbc\n line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 8, 1, 10, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First liAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 4);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 0, 1, 2, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nrst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 3, 1, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("FirAbc\nDefgh\n line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 8, 1, 10, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First liAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 0, 1, 2, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefghrst line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 3, 1, 5, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("FirAbc\nDefgh line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line", 1, 8, 1, 10, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("First liAbc\nDefgh");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 2, 8, 9);
+  }
+
+  @Test
+  public void replaceWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 10, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 10, 0, 3);
+  }
+
+  @Test
+  public void deleteWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 10, "");
+    assertThat(fixResult).text().isEqualTo("");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isDelete(0, 1, 0);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 10, 0);
+  }
+
+  @Test
+  public void deleteAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 0, 1, 4, "");
+    assertThat(fixResult).text().isEqualTo("t line");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 4, 0);
+  }
+
+  @Test
+  public void deleteInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 5, 1, 8, "");
+    assertThat(fixResult).text().isEqualTo("Firstne");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(5, 3, 5);
+  }
+
+  @Test
+  public void deleteAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line", 1, 7, 1, 10, "");
+    assertThat(fixResult).text().isEqualTo("First l");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(7, 3, 7);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/OneLineContentWithEOLTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/OneLineContentWithEOLTest.java
new file mode 100644
index 0000000..bae714b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/OneLineContentWithEOLTest.java
@@ -0,0 +1,320 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.fixes.fixCalculator;
+
+import static com.google.gerrit.server.fixes.testing.FixResultSubject.assertThat;
+import static com.google.gerrit.server.fixes.testing.GitEditSubject.assertThat;
+
+import com.google.gerrit.server.fixes.FixCalculator.FixResult;
+import org.eclipse.jgit.diff.Edit;
+import org.junit.Test;
+
+public class OneLineContentWithEOLTest {
+
+  @Test
+  public void insertSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 1, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("AbcFirst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 5, 1, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("FirstAbc line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 3);
+  }
+
+  @Test
+  public void insertSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 2, 0, 2, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("First line\nAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(1, 1, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 1, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nFirst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 5, 1, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("FirstAbc\n line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 4);
+  }
+
+  @Test
+  public void insertSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 2, 0, 2, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First line\nAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(1, 1, 1);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 0, 1, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nFirst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(0, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 5, 1, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("FirstAbc\nDefgh\n line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isInsert(5, 5, 10);
+  }
+
+  @Test
+  public void insertMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 2, 0, 2, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First line\nAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isInsert(1, 1, 2);
+    assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 1, 2, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abcrst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 3, 1, 5, "Abc");
+    assertThat(fixResult).text().isEqualTo("FirAbc line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 9, 2, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("First linAbc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(9, 2, 9, 3);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 1, 2, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nrst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 3, 1, 5, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("FirAbc\n line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 4);
+  }
+
+  @Test
+  public void replaceWithSingleLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 8, 2, 0, "Abc\n");
+    assertThat(fixResult).text().isEqualTo("First liAbc\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 3, 8, 4);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 0, 1, 2, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefgh\nrst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 3, 1, 5, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("FirAbc\nDefgh\n line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 3);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineWithEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 8, 2, 0, "Abc\nDefgh\n");
+    assertThat(fixResult).text().isEqualTo("First liAbc\nDefgh\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 3, 8, 10);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 0, 1, 2, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("Abc\nDefghrst line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 2, 0, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 3, 1, 5, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("FirAbc\nDefgh line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(3, 2, 3, 9);
+  }
+
+  @Test
+  public void replaceMultilineLineNoEOLAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement(
+            "First line\n", 1, 8, 2, 0, "Abc\nDefgh");
+    assertThat(fixResult).text().isEqualTo("First liAbc\nDefgh");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 2);
+    assertThat(edit).internalEdits().onlyElement().isReplace(8, 3, 8, 9);
+  }
+
+  @Test
+  public void replaceWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 2, 0, "Abc");
+    assertThat(fixResult).text().isEqualTo("Abc");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isReplace(0, 11, 0, 3);
+  }
+
+  @Test
+  public void deleteWholeContent() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 2, 0, "");
+    assertThat(fixResult).text().isEqualTo("");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isDelete(0, 1, 0);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 11, 0);
+  }
+
+  @Test
+  public void deleteAtStart() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 0, 1, 4, "");
+    assertThat(fixResult).text().isEqualTo("t line\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(0, 4, 0);
+  }
+
+  @Test
+  public void deleteInTheMiddle() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 5, 1, 8, "");
+    assertThat(fixResult).text().isEqualTo("Firstne\n");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(5, 3, 5);
+  }
+
+  @Test
+  public void deleteAtEnd() throws Exception {
+    FixResult fixResult =
+        FixCalculatorVariousTest.calculateFixSingleReplacement("First line\n", 1, 7, 2, 0, "");
+    assertThat(fixResult).text().isEqualTo("First l");
+    assertThat(fixResult).edits().hasSize(1);
+    Edit edit = fixResult.edits.get(0);
+    assertThat(edit).isReplace(0, 1, 0, 1);
+    assertThat(edit).internalEdits().onlyElement().isDelete(7, 4, 7);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
index 6175385..63f83b0 100644
--- a/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
+++ b/javatests/com/google/gerrit/server/git/GroupCollectorTest.java
@@ -21,10 +21,13 @@
 import com.google.common.collect.SortedSetMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.git.receive.ReceivePackRefCache;
+import java.io.IOException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -44,8 +47,7 @@
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(a, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(a, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
   }
@@ -56,8 +58,7 @@
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(b, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(b, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -67,12 +68,12 @@
   public void commitWhoseParentIsExistingPatchSetGetsParentsGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(a).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            newWalk(b, branchTip), patchSets().put(a, psId(1, 1)), groups().put(psId(1, 1), group));
+        collectGroups(newWalk(b, branchTip), groups().put(psId(1, 1), group));
 
     assertThat(groups).containsEntry(a, group);
     assertThat(groups).containsEntry(b, group);
@@ -84,8 +85,7 @@
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit b = tr.commit().parent(a).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(b, branchTip), patchSets().put(a, psId(1, 1)), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(b, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -98,8 +98,7 @@
     RevCommit b = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -110,13 +109,13 @@
   public void mergeCommitWhereOneParentHasExistingGroup() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            newWalk(m, branchTip), patchSets().put(b, psId(1, 1)), groups().put(psId(1, 1), group));
+        collectGroups(newWalk(m, branchTip), groups().put(psId(1, 1), group));
 
     // Merge commit and other parent get the existing group.
     assertThat(groups).containsEntry(a, group);
@@ -128,16 +127,16 @@
   public void mergeCommitWhereBothParentsHaveDifferentGroups() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
+    createRef(psId(2, 1), b, tr);
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     String group2 = "1234567812345678123456781234567812345678";
     SortedSetMultimap<ObjectId, String> groups =
         collectGroups(
-            newWalk(m, branchTip),
-            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
-            groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
+            newWalk(m, branchTip), groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
 
     assertThat(groups).containsEntry(a, group1);
     assertThat(groups).containsEntry(b, group2);
@@ -149,7 +148,9 @@
   public void mergeCommitMergesGroupsFromParent() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
+    createRef(psId(2, 1), b, tr);
     RevCommit m = tr.commit().parent(a).parent(b).create();
 
     String group1 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
@@ -158,7 +159,6 @@
     SortedSetMultimap<ObjectId, String> groups =
         collectGroups(
             newWalk(m, branchTip),
-            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
             groups().put(psId(1, 1), group1).put(psId(2, 1), group2a).put(psId(2, 1), group2b));
 
     assertThat(groups).containsEntry(a, group1);
@@ -171,12 +171,12 @@
   public void mergeCommitWithOneUninterestingParentAndOtherParentIsExisting() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit m = tr.commit().parent(branchTip).parent(a).create();
 
     String group = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            newWalk(m, branchTip), patchSets().put(a, psId(1, 1)), groups().put(psId(1, 1), group));
+        collectGroups(newWalk(m, branchTip), groups().put(psId(1, 1), group));
 
     assertThat(groups).containsEntry(a, group);
     assertThat(groups).containsEntry(m, group);
@@ -188,8 +188,7 @@
     RevCommit a = tr.commit().parent(branchTip).create();
     RevCommit m = tr.commit().parent(branchTip).parent(a).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(m, a.name());
@@ -204,8 +203,7 @@
     RevCommit m1 = tr.commit().parent(b).parent(c).create();
     RevCommit m2 = tr.commit().parent(a).parent(m1).create();
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m2, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m2, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -223,8 +221,7 @@
     assertThat(m.getParentCount()).isEqualTo(2);
     assertThat(m.getParent(0)).isEqualTo(m.getParent(1));
 
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(newWalk(m, branchTip), patchSets(), groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(newWalk(m, branchTip), groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(m, a.name());
@@ -234,7 +231,9 @@
   public void mergeCommitWithOneNewParentAndTwoExistingPatchSets() throws Exception {
     RevCommit branchTip = tr.commit().create();
     RevCommit a = tr.commit().parent(branchTip).create();
+    createRef(psId(1, 1), a, tr);
     RevCommit b = tr.commit().parent(branchTip).create();
+    createRef(psId(2, 1), b, tr);
     RevCommit c = tr.commit().parent(b).create();
     RevCommit m = tr.commit().parent(a).parent(c).create();
 
@@ -242,9 +241,7 @@
     String group2 = "1234567812345678123456781234567812345678";
     SortedSetMultimap<ObjectId, String> groups =
         collectGroups(
-            newWalk(m, branchTip),
-            patchSets().put(a, psId(1, 1)).put(b, psId(2, 1)),
-            groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
+            newWalk(m, branchTip), groups().put(psId(1, 1), group1).put(psId(2, 1), group2));
 
     assertThat(groups).containsEntry(a, group1);
     assertThat(groups).containsEntry(b, group2);
@@ -264,16 +261,7 @@
     rw.markStart(rw.parseCommit(d));
     // Schema upgrade case: all commits are existing patch sets, but none have
     // groups assigned yet.
-    SortedSetMultimap<ObjectId, String> groups =
-        collectGroups(
-            rw,
-            patchSets()
-                .put(branchTip, psId(1, 1))
-                .put(a, psId(2, 1))
-                .put(b, psId(3, 1))
-                .put(c, psId(4, 1))
-                .put(d, psId(5, 1)),
-            groups());
+    SortedSetMultimap<ObjectId, String> groups = collectGroups(rw, groups());
 
     assertThat(groups).containsEntry(a, a.name());
     assertThat(groups).containsEntry(b, a.name());
@@ -287,6 +275,13 @@
     return PatchSet.id(Change.id(c), p);
   }
 
+  private static void createRef(PatchSet.Id psId, ObjectId id, TestRepository<?> tr)
+      throws IOException {
+    RefUpdate ru = tr.getRepository().updateRef(psId.toRefName());
+    ru.setNewObjectId(id);
+    assertThat(ru.update()).isEqualTo(RefUpdate.Result.NEW);
+  }
+
   private RevWalk newWalk(ObjectId start, ObjectId branchTip) throws Exception {
     // Match RevWalk conditions from ReceiveCommits.
     RevWalk rw = new RevWalk(tr.getRepository());
@@ -297,12 +292,12 @@
     return rw;
   }
 
-  private static SortedSetMultimap<ObjectId, String> collectGroups(
-      RevWalk rw,
-      ImmutableListMultimap.Builder<ObjectId, PatchSet.Id> patchSetsBySha,
-      ImmutableListMultimap.Builder<PatchSet.Id, String> groupLookup)
-      throws Exception {
-    GroupCollector gc = new GroupCollector(patchSetsBySha.build(), groupLookup.build());
+  private SortedSetMultimap<ObjectId, String> collectGroups(
+      RevWalk rw, ImmutableListMultimap.Builder<PatchSet.Id, String> groupLookup) throws Exception {
+    ImmutableListMultimap<PatchSet.Id, String> groups = groupLookup.build();
+    GroupCollector gc =
+        new GroupCollector(
+            ReceivePackRefCache.noCache(tr.getRepository().getRefDatabase()), (s) -> groups.get(s));
     RevCommit c;
     while ((c = rw.next()) != null) {
       gc.visit(c);
@@ -310,12 +305,6 @@
     return gc.getGroups();
   }
 
-  // Helper methods for constructing various map arguments, to avoid lots of
-  // type specifications.
-  private static ImmutableListMultimap.Builder<ObjectId, PatchSet.Id> patchSets() {
-    return ImmutableListMultimap.builder();
-  }
-
   private static ImmutableListMultimap.Builder<PatchSet.Id, String> groups() {
     return ImmutableListMultimap.builder();
   }
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
new file mode 100644
index 0000000..698acd8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectIdRef;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.junit.Test;
+
+/** Tests for {@link ReceivePackRefCache}. */
+public class ReceivePackRefCacheTest {
+
+  @Test
+  public void noCache_prefixDelegatesToRefDb() throws Exception {
+    Ref ref = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    RefDatabase mockRefDb = mock(RefDatabase.class);
+    ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
+    when(mockRefDb.getRefsByPrefix(RefNames.REFS_HEADS)).thenReturn(ImmutableList.of(ref));
+
+    assertThat(cache.byPrefix(RefNames.REFS_HEADS)).containsExactly(ref);
+    verify(mockRefDb).getRefsByPrefix(RefNames.REFS_HEADS);
+    verifyNoMoreInteractions(mockRefDb);
+  }
+
+  @Test
+  public void noCache_exactRefDelegatesToRefDb() throws Exception {
+    Ref ref = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    RefDatabase mockRefDb = mock(RefDatabase.class);
+    ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
+    when(mockRefDb.exactRef("refs/heads/master")).thenReturn(ref);
+
+    assertThat(cache.exactRef("refs/heads/master")).isEqualTo(ref);
+    verify(mockRefDb).exactRef("refs/heads/master");
+    verifyNoMoreInteractions(mockRefDb);
+  }
+
+  @Test
+  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+    Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+    RefDatabase mockRefDb = mock(RefDatabase.class);
+    ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
+    when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
+        .thenReturn(ImmutableSet.of(refBla, refheads));
+
+    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
+        .containsExactly(refheads);
+    verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
+    verifyNoMoreInteractions(mockRefDb);
+  }
+
+  @Test
+  public void advertisedRefs_prefixScans() throws Exception {
+    Ref refBla =
+        new ObjectIdRef.Unpeeled(
+            Ref.Storage.NEW,
+            "refs/bla/1",
+            ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"),
+            1);
+    ReceivePackRefCache cache =
+        ReceivePackRefCache.withAdvertisedRefs(() -> ImmutableMap.of(refBla.getName(), refBla));
+
+    assertThat(cache.byPrefix("refs/bla")).containsExactly(refBla);
+  }
+
+  @Test
+  public void advertisedRefs_prefixScansChangeId() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(cache.byPrefix(RefNames.changeRefPrefix(Change.id(1))))
+        .containsExactly(refs.get("refs/changes/01/1/1"));
+  }
+
+  @Test
+  public void advertisedRefs_exactRef() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(cache.exactRef("refs/changes/01/1/1")).isEqualTo(refs.get("refs/changes/01/1/1"));
+  }
+
+  @Test
+  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(
+            cache.tipsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
+        .containsExactly(refs.get("refs/changes/01/1/1"));
+  }
+
+  @Test
+  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
+    Map<String, Ref> refs = setupTwoChanges();
+    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
+
+    assertThat(
+            cache.tipsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
+        .isEmpty();
+  }
+
+  private static Ref newRef(String name, String sha1) {
+    return new ObjectIdRef.Unpeeled(Ref.Storage.NEW, name, ObjectId.fromString(sha1), 1);
+  }
+
+  private Map<String, Ref> setupTwoChanges() {
+    Ref ref1 = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
+    Ref ref2 = newRef("refs/changes/02/2/1", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    return ImmutableMap.of(ref1.getName(), ref1, ref2.getName(), ref2);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index e54ab5d..bf2ade9 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
-import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import java.util.Set;
@@ -238,7 +238,7 @@
       int next, String groupName, PersonIdent authorIdent, Account.Id authorId) throws Exception {
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
-            .setGroupUUID(GroupUUID.make(groupName, serverIdent))
+            .setGroupUUID(GroupUuid.make(groupName, serverIdent))
             .setNameKey(AccountGroup.nameKey(groupName))
             .setId(AccountGroup.id(next))
             .build();
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 0753127..3dfbefe 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Ignore;
 
 @Ignore
@@ -26,8 +27,34 @@
     super(
         new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
-            null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, indexes, null, null, null, null, null, null, null));
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            indexes,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            new Config()));
   }
 
   @Operator
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index c887875..6f40680 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -163,34 +163,37 @@
     // Not stale.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P2, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P2, RefState.create(ref2, id2.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isFalse();
 
     // Wrong ref value.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, SHA1),
-                    P2, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, SHA1),
+                        P2, RefState.create(ref2, id2.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isTrue();
 
     // Swapped repos.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id2.name()),
-                    P2, RefState.create(ref2, id1.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id2.name()),
+                        P2, RefState.create(ref2, id1.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isTrue();
 
     // Two refs in same repo, not stale.
@@ -199,32 +202,35 @@
     tr1.update(ref3, id3);
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, id3.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref3, id3.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isFalse();
 
     // Ignore ref not mentioned.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isFalse();
 
     // One ref wrong.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, SHA1)),
-                ImmutableListMultimap.of()))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref3, SHA1)),
+                    ImmutableListMultimap.of())
+                .isStale())
         .isTrue();
   }
 
@@ -236,10 +242,11 @@
     // ref1 is only ref matching pattern.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*")))
+                .isStale())
         .isFalse();
 
     // Now ref2 matches pattern, so stale unless ref2 is present in state map.
@@ -247,19 +254,21 @@
     ObjectId id2 = tr1.update(ref2, tr1.commit().message("commit 2"));
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*")))
+                .isStale())
         .isTrue();
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref2, id2.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref2, id2.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/heads/*")))
+                .isStale())
         .isFalse();
   }
 
@@ -272,10 +281,11 @@
     // ref1 is only ref matching pattern.
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo")))
+                .isStale())
         .isFalse();
 
     // Now ref2 matches pattern, so stale unless ref2 is present in state map.
@@ -283,19 +293,21 @@
     ObjectId id3 = tr1.update(ref3, tr1.commit().message("commit 3"));
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(P1, RefState.create(ref1, id1.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo")))
+                .isStale())
         .isTrue();
     assertThat(
             refsAreStale(
-                repoManager,
-                C,
-                ImmutableSetMultimap.of(
-                    P1, RefState.create(ref1, id1.name()),
-                    P1, RefState.create(ref3, id3.name())),
-                ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo"))))
+                    repoManager,
+                    C,
+                    ImmutableSetMultimap.of(
+                        P1, RefState.create(ref1, id1.name()),
+                        P1, RefState.create(ref3, id3.name())),
+                    ImmutableListMultimap.of(P1, RefStatePattern.create("refs/*/foo")))
+                .isStale())
         .isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 6ece894..16981dc 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -776,6 +776,7 @@
                 .put("workInProgress", boolean.class)
                 .put("reviewStarted", boolean.class)
                 .put("revertOf", Change.Id.class)
+                .put("cherryPickOf", PatchSet.Id.class)
                 .put("toBuilder", ChangeNotesState.ChangeColumns.Builder.class)
                 .build());
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 145e914..db0dec8 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -52,7 +53,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
@@ -432,7 +432,7 @@
   @Test
   public void approvalsPostSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = submissionId(c);
+    SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -467,7 +467,7 @@
   @Test
   public void approvalsDuringSubmit() throws Exception {
     Change c = newChange();
-    RequestId submissionId = submissionId(c);
+    SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putApproval("Code-Review", (short) 1);
     update.putApproval("Verified", (short) 1);
@@ -604,7 +604,7 @@
   @Test
   public void submitRecords() throws Exception {
     Change c = newChange();
-    RequestId submissionId = submissionId(c);
+    SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
@@ -640,13 +640,13 @@
                 null,
                 submitLabel("Verified", "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null)));
-    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
+    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
 
   @Test
   public void latestSubmitRecordsOnly() throws Exception {
     Change c = newChange();
-    RequestId submissionId = submissionId(c);
+    SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
     update.merge(
@@ -669,7 +669,7 @@
     assertThat(notes.getSubmitRecords())
         .containsExactly(
             submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
-    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toStringForStorage());
+    assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
 
   @Test
@@ -977,7 +977,7 @@
     // Finish off by merging the change.
     update = newUpdate(c, changeOwner);
     update.merge(
-        submissionId(c),
+        new SubmissionId(c),
         ImmutableList.of(
             submitRecord(
                 "NOT_READY",
@@ -3140,8 +3140,4 @@
     update.commit();
     return tr.parseBody(commit);
   }
-
-  private RequestId submissionId(Change c) {
-    return new RequestId(c.getId().toString());
-  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 97781a4..5e25c13 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestChanges;
@@ -151,7 +151,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = submissionId(c);
+    SubmissionId submissionId = new SubmissionId(c);
     update.merge(
         submissionId,
         ImmutableList.of(
@@ -174,7 +174,7 @@
             + "Patch-set: 1\n"
             + "Status: merged\n"
             + "Submission-id: "
-            + submissionId.toStringForStorage()
+            + submissionId.toString()
             + "\n"
             + "Submitted-with: NOT_READY\n"
             + "Submitted-with: OK: Verified: Gerrit User 1 <1@gerrit>\n"
@@ -223,7 +223,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setSubjectForCommit("Submit patch set 1");
 
-    RequestId submissionId = submissionId(c);
+    SubmissionId submissionId = new SubmissionId(c);
     update.merge(
         submissionId, ImmutableList.of(submitRecord("RULE_ERROR", "Problem with patch set:\n1")));
     update.commit();
@@ -234,7 +234,7 @@
             + "Patch-set: 1\n"
             + "Status: merged\n"
             + "Submission-id: "
-            + submissionId.toStringForStorage()
+            + submissionId.toString()
             + "\n"
             + "Submitted-with: RULE_ERROR Problem with patch set: 1\n",
         update.getResult());
@@ -427,8 +427,4 @@
     RevCommit commit = parseCommit(commitId);
     assertThat(commit.getFullMessage()).isEqualTo(expected);
   }
-
-  private RequestId submissionId(Change c) {
-    return new RequestId(c.getId().toString());
-  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 2c5fcc4..38e4ca4 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -41,6 +41,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
@@ -79,6 +80,7 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -2023,6 +2025,9 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "change.mergeabilityComputationBehavior",
+      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void mergeable() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
@@ -2040,7 +2045,7 @@
     // If a change gets submitted, the remaining open changes get reindexed asynchronously to update
     // their mergeability information. If the further assertions in this test are done before the
     // asynchronous reindex completed they fail because the mergeability information in the index
-    // was not updated yet. To avoid this flakiness reindexAfterRefUpdate is switched off for the
+    // was not updated yet. To avoid this flakiness indexing mergeable is switched off for the
     // tests and we index change2 synchronously here.
     gApi.changes().id(change2.getChangeId()).index();
 
@@ -2502,6 +2507,19 @@
     assertQueryByIds("revertof:" + changeToRevert._number, Change.id(changeThatReverts._number));
   }
 
+  @Test
+  public void submissionId() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change = insert(repo, newChange(repo));
+    // create irrelevant change
+    insert(repo, newChange(repo));
+    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change.getChangeId()).current().submit();
+    String submissionId = gApi.changes().id(change.getChangeId()).get().submissionId;
+
+    assertQueryByIds("submissionid:" + submissionId, change.getId());
+  }
+
   /** Change builder for helping in tests for dashboard sections. */
   protected class DashboardChangeState {
     private final Account.Id ownerId;
@@ -3066,6 +3084,20 @@
     }
   }
 
+  @Test
+  @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
+  public void mergeableFailsWhenNotIndexed() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
+    insert(repo, newChangeForCommit(repo, commit1));
+
+    Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
+    assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("'is:mergeable' operator is not supported by server");
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null, false);
   }
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index d0162d3..e5b51e7 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -16,12 +16,14 @@
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index d5ddeff..b65f4d2 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.schema;
 
-import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewLabel;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
 import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
@@ -31,7 +30,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
@@ -102,7 +101,7 @@
   }
 
   private GroupReference createGroupReference(String name) {
-    AccountGroup.UUID groupUuid = GroupUUID.make(name, serverUser);
+    AccountGroup.UUID groupUuid = GroupUuid.make(name, serverUser);
     return new GroupReference(groupUuid, name);
   }
 
diff --git a/javatests/com/google/gerrit/server/schema/TestGroup.java b/javatests/com/google/gerrit/server/schema/TestGroup.java
deleted file mode 100644
index cca6d6c..0000000
--- a/javatests/com/google/gerrit/server/schema/TestGroup.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.util.time.TimeUtil;
-import java.sql.Timestamp;
-import java.util.Optional;
-import org.junit.Ignore;
-
-@AutoValue
-@Ignore
-public abstract class TestGroup {
-  abstract Optional<AccountGroup.NameKey> getNameKey();
-
-  abstract Optional<AccountGroup.UUID> getGroupUuid();
-
-  abstract Optional<AccountGroup.Id> getId();
-
-  abstract Optional<Timestamp> getCreatedOn();
-
-  abstract Optional<AccountGroup.UUID> getOwnerGroupUuid();
-
-  abstract Optional<String> getDescription();
-
-  abstract boolean isVisibleToAll();
-
-  public static Builder builder() {
-    return new AutoValue_TestGroup.Builder().setVisibleToAll(false);
-  }
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract Builder setNameKey(AccountGroup.NameKey nameKey);
-
-    public Builder setName(String name) {
-      return setNameKey(AccountGroup.nameKey(name));
-    }
-
-    public abstract Builder setGroupUuid(AccountGroup.UUID uuid);
-
-    public abstract Builder setId(AccountGroup.Id id);
-
-    public abstract Builder setCreatedOn(Timestamp createdOn);
-
-    public abstract Builder setOwnerGroupUuid(AccountGroup.UUID ownerGroupUuid);
-
-    public abstract Builder setDescription(String description);
-
-    public abstract Builder setVisibleToAll(boolean visibleToAll);
-
-    public abstract TestGroup autoBuild();
-
-    public AccountGroup build() {
-      TestGroup testGroup = autoBuild();
-      AccountGroup.NameKey name = testGroup.getNameKey().orElse(AccountGroup.nameKey("users"));
-      AccountGroup.Id id = testGroup.getId().orElse(AccountGroup.id(Math.abs(name.hashCode())));
-      AccountGroup.UUID uuid = testGroup.getGroupUuid().orElse(AccountGroup.uuid(name + "-UUID"));
-      Timestamp createdOn = testGroup.getCreatedOn().orElseGet(TimeUtil::nowTs);
-      AccountGroup accountGroup = new AccountGroup(name, id, uuid, createdOn);
-      testGroup.getOwnerGroupUuid().ifPresent(accountGroup::setOwnerGroupUUID);
-      testGroup.getDescription().ifPresent(accountGroup::setDescription);
-      accountGroup.setVisibleToAll(testGroup.isVisibleToAll());
-      return accountGroup;
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index d1030b5..e175b95 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -14,7 +14,6 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index a2f800a..083493d 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
@@ -26,20 +27,22 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.notedb.TooManyUpdatesException;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.name.Named;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -51,12 +54,15 @@
 
 public class BatchUpdateTest {
   private static final int MAX_UPDATES = 4;
+  private static final int MAX_PATCH_SETS = 3;
 
   @Rule
   public InMemoryTestEnvironment testEnvironment =
       new InMemoryTestEnvironment(
           () -> {
             Config cfg = new Config();
+            cfg.setInt("change", null, "maxFiles", 2);
+            cfg.setInt("change", null, "maxPatchSets", MAX_PATCH_SETS);
             cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
             return cfg;
           });
@@ -69,6 +75,9 @@
   @Inject private Provider<CurrentUser> user;
   @Inject private Sequences sequences;
 
+  @Inject
+  private @Named("diff_summary") Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
+
   private Project.NameKey project;
   private TestRepository<Repository> repo;
 
@@ -106,11 +115,10 @@
     ObjectId oldMetaId = getMetaId(id);
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
       bu.addOp(id, new AddMessageOp("Excessive update"));
-      ResourceConflictException thrown =
-          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
       assertThat(thrown)
           .hasMessageThat()
-          .isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+          .contains("Change " + id + " may not exceed " + MAX_UPDATES);
     }
     assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES);
     assertThat(getMetaId(id)).isEqualTo(oldMetaId);
@@ -118,17 +126,16 @@
 
   @Test
   public void cannotExceedMaxUpdatesCountingMultipleChangeUpdatesInSingleBatch() throws Exception {
-    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+    Change.Id id = createChangeWithPatchSets(2);
 
     ObjectId oldMetaId = getMetaId(id);
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
       bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
-      ResourceConflictException thrown =
-          assertThrows(ResourceConflictException.class, () -> bu.execute());
+      ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
       assertThat(thrown)
           .hasMessageThat()
-          .isEqualTo(TooManyUpdatesException.message(id, MAX_UPDATES));
+          .contains("Change " + id + " may not exceed " + MAX_UPDATES);
     }
     assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES - 1);
     assertThat(getMetaId(id)).isEqualTo(oldMetaId);
@@ -187,7 +194,7 @@
 
   @Test
   public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
-    Change.Id id = createChangeWithTwoPatchSets(MAX_UPDATES - 1);
+    Change.Id id = createChangeWithPatchSets(2);
     ObjectId oldMetaId = getMetaId(id);
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
       bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
@@ -222,6 +229,81 @@
     assertThat(getMetaId(id)).isNotEqualTo(oldMetaId);
   }
 
+  @Test
+  public void limitPatchSetCount_exceed() throws Exception {
+    Change.Id changeId = createChangeWithPatchSets(MAX_PATCH_SETS);
+    ObjectId oldMetaId = getMetaId(changeId);
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      ObjectId commitId =
+          repo.amend(notes.getCurrentPatchSet().commitId()).message("kaboom").create();
+      bu.addOp(
+          changeId,
+          patchSetInserterFactory
+              .create(notes, PatchSet.id(changeId, MAX_PATCH_SETS + 1), commitId)
+              .setMessage("kaboom"));
+      ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Change " + changeId + " may not exceed " + MAX_PATCH_SETS + " patch sets");
+    }
+    assertThat(changeNotesFactory.create(project, changeId).getPatchSets()).hasSize(MAX_PATCH_SETS);
+    assertThat(getMetaId(changeId)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void limitFileCount_exceed() throws Exception {
+    Change.Id changeId = createChangeWithUpdates(1);
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      ObjectId commitId =
+          repo.amend(notes.getCurrentPatchSet().commitId())
+              .add("bar.txt", "bar")
+              .add("baz.txt", "baz")
+              .add("boom.txt", "boom")
+              .message("blah")
+              .create();
+      bu.addOp(
+          changeId,
+          patchSetInserterFactory
+              .create(notes, PatchSet.id(changeId, 2), commitId)
+              .setMessage("blah"));
+      ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Exceeding maximum number of files per change (3 > 2)");
+    }
+  }
+
+  @Test
+  public void limitFileCount_cacheKeyMatches() throws Exception {
+    Change.Id changeId = createChangeWithUpdates(1);
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+
+    int cacheSizeBefore = diffSummaryCache.asMap().size();
+
+    // We don't want to depend on the test helper used above so we perform an explicit commit here.
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      ObjectId commitId =
+          repo.amend(notes.getCurrentPatchSet().commitId())
+              .add("bar.txt", "bar")
+              .add("baz.txt", "baz")
+              .message("blah")
+              .create();
+      bu.addOp(
+          changeId,
+          patchSetInserterFactory
+              .create(notes, PatchSet.id(changeId, 3), commitId)
+              .setMessage("blah"));
+      bu.execute();
+    }
+
+    // Assert that we only performed the diff computation once. This would e.g. catch
+    // bugs/deviations in the computation of the cache key.
+    assertThat(diffSummaryCache.asMap()).hasSize(cacheSizeBefore + 1);
+  }
+
   private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
     checkArgument(totalUpdates > 0);
     checkArgument(totalUpdates <= MAX_UPDATES);
@@ -243,21 +325,22 @@
     return id;
   }
 
-  private Change.Id createChangeWithTwoPatchSets(int totalUpdates) throws Exception {
-    Change.Id id = createChangeWithUpdates(totalUpdates - 1);
+  private Change.Id createChangeWithPatchSets(int patchSets) throws Exception {
+    checkArgument(patchSets >= 2);
+    Change.Id id = createChangeWithUpdates(MAX_UPDATES - 2);
     ChangeNotes notes = changeNotesFactory.create(project, id);
-
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
-      ObjectId commitId = repo.amend(notes.getCurrentPatchSet().commitId()).message("PS2").create();
-      bu.addOp(
-          id,
-          patchSetInserterFactory
-              .create(notes, PatchSet.id(id, 2), commitId)
-              .setMessage("Add PS2"));
-      bu.execute();
+    for (int i = 2; i <= patchSets; ++i) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+        ObjectId commitId =
+            repo.amend(notes.getCurrentPatchSet().commitId()).message("PS" + i).create();
+        bu.addOp(
+            id,
+            patchSetInserterFactory
+                .create(notes, PatchSet.id(id, i), commitId)
+                .setMessage("Add PS" + i));
+        bu.execute();
+      }
     }
-
-    assertThat(getUpdateCount(id)).isEqualTo(totalUpdates);
     return id;
   }
 
@@ -291,7 +374,7 @@
     }
   }
 
-  private int getUpdateCount(Change.Id changeId) throws Exception {
+  private int getUpdateCount(Change.Id changeId) {
     return changeNotesFactory.create(project, changeId).getUpdateCount();
   }
 
@@ -310,7 +393,7 @@
       cr.label = "Code-Review";
       sr.labels = ImmutableList.of(cr);
       ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-      update.merge(new RequestId(), ImmutableList.of(sr));
+      update.merge(new SubmissionId(ctx.getChange()), ImmutableList.of(sr));
       update.setChangeMessage("Submitted");
       return true;
     }
diff --git a/javatests/com/google/gerrit/server/util/git/BUILD b/javatests/com/google/gerrit/server/util/git/BUILD
index 883898f..b789dff 100644
--- a/javatests/com/google/gerrit/server/util/git/BUILD
+++ b/javatests/com/google/gerrit/server/util/git/BUILD
@@ -18,7 +18,6 @@
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:codec",
         "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
diff --git a/lib/BUILD b/lib/BUILD
index 2e5668e..f0c0aad 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -530,7 +530,7 @@
         "//java/com/google/gerrit/acceptance:__pkg__",
         "//java/com/google/gerrit/extensions:__pkg__",
         "//java/com/google/gerrit/server:__pkg__",
-        "//plugins:__pkg__",
+        "//plugins:__subpackages__",
     ],
     exports = ["@javax-annotation//jar"],
 )
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD
index 68da16a..3a400a4 100644
--- a/lib/greenmail/BUILD
+++ b/lib/greenmail/BUILD
@@ -2,8 +2,6 @@
 
 package(default_visibility = ["//visibility:public"])
 
-POST_JDK8_DEPS = [":javax-activation"]
-
 java_library(
     name = "javax-activation",
     testonly = True,
@@ -16,9 +14,5 @@
     testonly = True,
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@greenmail//jar"],
-    runtime_deps = select({
-        "//:java11": POST_JDK8_DEPS,
-        "//:java_next": POST_JDK8_DEPS,
-        "//conditions:default": [],
-    }),
+    runtime_deps = [":javax-activation"],
 )
diff --git a/lib/guava.bzl b/lib/guava.bzl
index 18a8355..86060d4 100644
--- a/lib/guava.bzl
+++ b/lib/guava.bzl
@@ -1,5 +1,5 @@
-GUAVA_VERSION = "28.1-jre"
+GUAVA_VERSION = "28.2-jre"
 
-GUAVA_BIN_SHA1 = "b0e91dcb6a44ffb6221b5027e12a5cb34b841145"
+GUAVA_BIN_SHA1 = "8ec9ed76528425762174f0011ce8f74ad845b756"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD
index 6417385..fe07794 100644
--- a/lib/jetty/BUILD
+++ b/lib/jetty/BUILD
@@ -21,7 +21,6 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = [
-        ":continuation",
         ":http",
         "@jetty-server//jar",
     ],
@@ -32,20 +31,12 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = [
-        ":continuation",
         ":http",
         "@jetty-jmx//jar",
     ],
 )
 
 java_library(
-    name = "continuation",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jetty-continuation//jar"],
-)
-
-java_library(
     name = "http",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/modules/jgit b/modules/jgit
index 9710c62..a7e454b 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 9710c6292ab0c8db28c6e3051ff7f1cca9e9a031
+Subproject commit a7e454bc51d359c2d46b19fd559f770cad8fd7d4
diff --git a/package.json b/package.json
index 08ffc55..600f1ab 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     "eslint-plugin-html": "^6.0.0",
     "eslint-plugin-jsdoc": "^18.4.3",
     "fried-twinkie": "^0.2.2",
-    "polylint": "^2.10.4",
+    "polymer-cli": "^1.9.11",
     "typescript": "^2.x.x",
     "web-component-tester": "^6.5.0"
   },
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 92ce310..9508693 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 92ce310ecf717133601b9e824c38bc5e5eafecba
+Subproject commit 9508693bd0a85560674143256314beac08f2d8ca
diff --git a/plugins/delete-project b/plugins/delete-project
index 5296f45..180fd9d 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 5296f452067ba54f3546edee879b91338a8dfc34
+Subproject commit 180fd9dbd7f1661d16bf05ca8a16c74bfcc9bc67
diff --git a/plugins/gitiles b/plugins/gitiles
index 0912a48..825ca06 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 0912a48d51981d86e7912c8d682ce75d62afb81d
+Subproject commit 825ca06dddc9de89daa6b126dfc187fbeb25280c
diff --git a/plugins/hooks b/plugins/hooks
index 22d1dbb..6316be2 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 22d1dbbbd2b34dc066e20e6fb26a8623f1ae47fc
+Subproject commit 6316be2828808dafc546ecd11c055396d0b4951b
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index c921234..2933add 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit c921234cec4c0d058b35a8d117f55fce4d4c5e65
+Subproject commit 2933add62ecf2cbfc28cfe2cff81ff0e0eecc913
diff --git a/plugins/replication b/plugins/replication
index 56b7814..0bf9f5a 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 56b7814354c04448da9ba5fddcbe732de02f68c1
+Subproject commit 0bf9f5ae26220bfdcff0b3332e810f00aa9a7789
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index f1a3622..eac4cd9 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit f1a36220e0ef31fb024de9ad589dfdfdf301c295
+Subproject commit eac4cd97cb5818ff471c64914fb4e342baf28c05
diff --git a/plugins/webhooks b/plugins/webhooks
index 377fec1..570daca 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 377fec17d2c0fc71cdfb1d12f502cfa1eba1fbd7
+Subproject commit 570dacacc64f7c01e0bc0f1301aa1d5a218cfc1b
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 1fbf581..a35bd6f 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -46,6 +46,9 @@
 
 ```sh
 ./polygerrit-ui/run-server.sh
+
+// or
+npm run start
 ```
 
 Then visit http://localhost:8081
@@ -64,14 +67,16 @@
 The biggest draw back of this method is that you cannot log in, so cannot test
 scenarios that require it.
 
-#### MITM Proxy
+#### Chrome extension: Gerrit FE Dev Helper
 
-[MITM Proxy](https://mitmproxy.org/) is an open source product for proxying
-https servers. The
-[contrib/mitm-ui/](https://gerrit.googlesource.com/gerrit/+/master/contrib/mitm-ui/)
-directory contains scripts (and documentation) for using this technology
-(instead of the Go server). These scripts are somewhat experimental and
-unmaintained though.
+To be able to bypass the auth and also help improve the productivity of Gerrit FE developers,
+we created this chrome extension: [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd).
+
+It basically works as a proxy that will block / redirect requests from current sites to any given url base on certain rules.
+
+The source code is in [Gerrit - gerrit-fe-dev-helper](https://gerrit-review.googlesource.com/q/project:gerrit-fe-dev-helper), contributions are welcomed!
+
+To use this extension, just follow its [readme here](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
 
 ## Running locally against a Gerrit test site
 
@@ -182,6 +187,11 @@
 ```
 
 ## Template Type Safety
+
+> **Warning**: This feature is temporary disabled, because it doesn't work with Polymer 2 and Polymer 3.
+Some of the checks are made by polymer linter.
+
+
 Polymer elements are not type checked against the element definition, making it
 trivial to break the display when refactoring or moving code. We now run
 additional tests to help ensure that template types are checked.
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
index fb1a5b3..c14b787 100644
--- a/polygerrit-ui/app/.eslintrc.json
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -21,23 +21,26 @@
     "flushAsynchronousOperations": false
   },
   "rules": {
+    "no-confusing-arrow": "error",
+    "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }],
+    "arrow-body-style": ["error", "as-needed", { "requireReturnForObjectLiteral": true }],
     "arrow-parens": ["error", "as-needed"],
     "block-spacing": ["error", "always"],
     "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
     "camelcase": "off",
     "comma-dangle": ["error", {
-        "arrays": "always-multiline",
-        "objects": "always-multiline",
-        "imports": "always-multiline",
-        "exports": "always-multiline",
-        "functions": "never"
+      "arrays": "always-multiline",
+      "objects": "always-multiline",
+      "imports": "always-multiline",
+      "exports": "always-multiline",
+      "functions": "never"
     }],
     "eol-last": "off",
     "indent": ["error", 2, {
       "MemberExpression": 2,
       "FunctionDeclaration": {"body": 1, "parameters": 2},
       "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2},
+      "CallExpression": {"arguments": 2 },
       "ArrayExpression": 1,
       "ObjectExpression": 1,
       "SwitchCase": 1
@@ -52,6 +55,9 @@
     ],
     "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }],
     "no-console": "off",
+    "no-multiple-empty-lines": [ "error", { "max": 1 } ],
+    "no-prototype-builtins": "off",
+    "no-redeclare": "off",
     "no-restricted-syntax": [
       "error",
       {
@@ -66,8 +72,6 @@
     "no-undef": "off",
     "no-useless-escape": "off",
     "no-var": "error",
-    "no-prototype-builtins": "off",
-    "no-redeclare": "off",
     "operator-linebreak": "off",
     "object-shorthand": ["error", "always"],
     "padding-line-between-statements": [
@@ -85,6 +89,7 @@
     ],
     "prefer-arrow-callback": "error",
     "prefer-const": "error",
+    "prefer-promise-reject-errors": "error",
     "prefer-spread": "error",
     "quote-props": ["error", "consistent-as-needed"],
     "semi": [2, "always"],
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 239ad0b..e21dbe2 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -45,6 +45,22 @@
     ),
 )
 
+filegroup(
+    name = "pg_code_without_test",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+        ],
+        exclude = [
+            "bower_components/**",
+            "**/*_test.html",
+            "test/**",
+            "samples/**",
+        ],
+    ),
+)
+
 genrule2(
     name = "pg_code_zip",
     srcs = [":pg_code"],
@@ -77,6 +93,7 @@
     ],
 )
 
+# TODO(taoalpha): alias to `npm run eslint` or just remove once CI moved to npm
 sh_test(
     name = "lint_test",
     size = "large",
@@ -97,7 +114,8 @@
     size = "large",
     srcs = ["polylint_test.sh"],
     data = [
-        ":pg_code",
+        "polymer.json",
+        ":pg_code_without_test",
         "//polygerrit-ui:polygerrit_components.bower_components.zip",
     ],
     # Should not run sandboxed.
@@ -137,33 +155,6 @@
     ],
 ) for directory in DIRECTORIES]
 
-# Embed bundle
-polygerrit_bundle(
-    name = "polygerrit_embed_ui",
-    srcs = glob(
-        [
-            "**/*.html",
-            "**/*.js",
-        ],
-        exclude = [
-            "bower_components/**",
-            "test/**",
-            "**/*_test.html",
-        ],
-    ),
-    outs = ["polygerrit_embed_ui.zip"],
-    app = "embed/embed.html",
-)
-
-filegroup(
-    name = "embed_test_files",
-    srcs = glob(
-        [
-            "embed/**/*_test.html",
-        ],
-    ),
-)
-
 filegroup(
     name = "template_test_srcs",
     srcs = [
@@ -171,21 +162,3 @@
         "template_test_srcs/template_test.js",
     ],
 )
-
-sh_test(
-    name = "embed_test",
-    size = "small",
-    srcs = ["embed_test.sh"],
-    data = [
-        "embed/test.html",
-        "test/common-test-setup.html",
-        ":embed_test_files",
-        ":pg_code.zip",
-        ":test_components.zip",
-    ],
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
-    ],
-)
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
index 36e0201..f560ea8 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
@@ -45,5 +45,22 @@
       });
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.AsyncForeachMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
index 970bfc7..03ba6b1 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>async-foreach-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
index 1748647..92596e0 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -28,5 +28,22 @@
       return window.CANONICAL_PATH || '';
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.BaseUrlMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index b61b142..5669bc4 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>base-url-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
index f07a955..05a7a58 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
@@ -59,5 +59,22 @@
   },
   Gerrit.BaseUrlBehavior,
   ];
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.DocsUrlMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
index 2c513f3..e554012 100644
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
@@ -19,7 +19,7 @@
 <script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <title>docs-url-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="docs-url-behavior.html">
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.html b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.html
index e0a15cf..1377627 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.html
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.html
@@ -43,5 +43,22 @@
       return isDescendant;
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.DomUtilMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
index 8323ac6..a52e0e2 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>dom-util-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
index b5afab1..5b3d420 100644
--- a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
+++ b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.html
@@ -51,5 +51,22 @@
       return event;
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.FireMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
index 0c75c44..7f01789 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
@@ -139,16 +139,42 @@
      */
     toSortedArray(obj) {
       if (!obj) { return []; }
-      return Object.keys(obj).map(key => {
-        return {
-          id: key,
-          value: obj[key],
-        };
-      }).sort((a, b) => {
-        // Since IDs are strings, use localeCompare.
-        return a.id.localeCompare(b.id);
-      });
+      return Object.keys(obj)
+          .map(key => {
+            return {
+              id: key,
+              value: obj[key],
+            };
+          })
+          .sort((a, b) =>
+            // Since IDs are strings, use localeCompare.
+            a.id.localeCompare(b.id)
+          );
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.AccessMixin = base =>
+      class extends base {
+        static get properties() {
+          return {
+            permissionValues: {
+              type: Object,
+            },
+          };
+        }
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
index 0d1ee57..48565a6 100644
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
index 182d242..49160da 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.html
@@ -64,12 +64,11 @@
             getAdminMenuLinks, opt_options));
       }
       return getAccountCapabilities()
-          .then(capabilities => {
-            return this._filterLinks(link => {
-              return !link.capability ||
-                  capabilities.hasOwnProperty(link.capability);
-            }, getAdminMenuLinks, opt_options);
-          });
+          .then(capabilities => this._filterLinks(
+              link => !link.capability
+              || capabilities.hasOwnProperty(link.capability),
+              getAdminMenuLinks,
+              opt_options));
     },
 
     /**
@@ -92,15 +91,17 @@
       const isExernalLink = link => link.url[0] !== '/';
 
       // Append top-level links that are defined by plugins.
-      links.push(...getAdminMenuLinks().map(link => ({
-        url: link.url,
-        name: link.text,
-        capability: link.capability || null,
-        noBaseUrl: !isExernalLink(link),
-        view: null,
-        viewableToAll: !link.capability,
-        target: isExernalLink(link) ? '_blank' : null,
-      })));
+      links.push(...getAdminMenuLinks().map(link => {
+        return {
+          url: link.url,
+          name: link.text,
+          capability: link.capability || null,
+          noBaseUrl: !isExernalLink(link),
+          view: null,
+          viewableToAll: !link.capability,
+          target: isExernalLink(link) ? '_blank' : null,
+        };
+      }));
 
       links = links.filter(filterFn);
 
@@ -201,5 +202,22 @@
       };
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.AdminNavMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
index 0285e35..d02483f 100644
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -95,9 +95,8 @@
 
             if (expected.pluginGeneratedLinks) {
               for (const link of expected.pluginGeneratedLinks) {
-                const linkMatch = res.links.find(l => {
-                  return (l.url === link.url && l.name === link.text);
-                });
+                const linkMatch = res.links
+                    .find(l => (l.url === link.url && l.name === link.text));
                 assert.isTrue(!!linkMatch);
 
                 // External links should open in new tab.
@@ -310,7 +309,6 @@
       });
     });
 
-
     suite('view plugin screen with plugin capability', () => {
       const account = {
         name: 'test-user',
@@ -326,7 +324,9 @@
         let options;
         const generatedLinks = [
           {text: 'without capability', url: '/without'},
-          {text: 'with capability', url: '/with', capability: 'pluginCapability'},
+          {text: 'with capability',
+            url: '/with',
+            capability: 'pluginCapability'},
         ];
         menuLinkStub.returns(generatedLinks);
         expected = Object.assign(expected, {
@@ -337,7 +337,6 @@
       });
     });
 
-
     suite('view plugin screen without plugin capability', () => {
       const account = {
         name: 'test-user',
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
index f81fef0..d03316a 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
@@ -46,9 +46,7 @@
      * @return {!Array}
      */
     getComplementColumns(columns) {
-      return this.columnNames.filter(column => {
-        return !columns.includes(column);
-      });
+      return this.columnNames.filter(column => !columns.includes(column));
     },
 
     /**
@@ -57,6 +55,9 @@
      * @return {boolean}
      */
     isColumnHidden(columnToCheck, columnsToDisplay) {
+      if ([columnsToDisplay, columnToCheck].some(arg => arg === undefined)) {
+        return false;
+      }
       return !columnsToDisplay.includes(columnToCheck);
     },
 
@@ -77,5 +78,31 @@
       return newColumns;
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.ChangeTableMixin = base =>
+      class extends base {
+        static get properties() {
+          return {
+            columnNames: {
+              type: Array,
+            },
+          };
+        }
+
+        isColumnHidden(columnToCheck, columnsToDisplay) {}
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index 791e2af..52931bc 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
index 3106fc8..e5ded0e 100644
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.html
@@ -39,5 +39,22 @@
       return GrDisplayNameUtils.getGroupDisplayName(group);
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.DisplayNameMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
index 3d4eca1..aa217a1 100644
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-display-name-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
index b6edb57..06912d5 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
@@ -59,5 +59,26 @@
   Gerrit.BaseUrlBehavior,
   Gerrit.URLEncodingBehavior,
   ];
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.ListViewMixin = base =>
+      class extends base {
+        computeLoadingClass(loading) {}
+
+        computeShownItems(items) {}
+      };
+  }
 })(window);
+
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
index 535483d..9b48cdc 100644
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 28d6990..61e6b0a 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -54,7 +54,7 @@
      * number).
      *
      * @param  {string|number} n
-     * @return {Boolean}
+     * @return {boolean}
      */
     isMergeParent(n) {
       return (n + '')[0] === '-';
@@ -124,9 +124,9 @@
       // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
       // 2 -> 3, 3 -> 5, etc.
       // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
-      const num = r => r._number === Gerrit.PatchSetBehavior.EDIT_NAME ?
+      const num = r => (r._number === Gerrit.PatchSetBehavior.EDIT_NAME ?
         2 * editParent :
-        2 * (r._number - 1) + 1;
+        2 * (r._number - 1) + 1);
       return revisions.sort((a, b) => num(b) - num(a));
     },
 
@@ -150,9 +150,8 @@
       if (!change) { return []; }
       let patchNums = [];
       if (change.revisions && Object.keys(change.revisions).length) {
-        const revisions = Object.keys(change.revisions).map(sha => {
-          return Object.assign({sha}, change.revisions[sha]);
-        });
+        const revisions = Object.keys(change.revisions)
+            .map(sha => Object.assign({sha}, change.revisions[sha]));
         patchNums =
           Gerrit.PatchSetBehavior.sortRevisions(revisions)
               .map(e => {
@@ -210,13 +209,13 @@
       return allPatchSets[0].num;
     },
 
-    /** @return {Boolean} */
+    /** @return {boolean} */
     hasEditBasedOnCurrentPatchSet(allPatchSets) {
       if (!allPatchSets || allPatchSets.length < 2) { return false; }
       return allPatchSets[0].num === Gerrit.PatchSetBehavior.EDIT_NAME;
     },
 
-    /** @return {Boolean} */
+    /** @return {boolean} */
     hasEditPatchsetLoaded(patchRangeRecord) {
       const patchRange = patchRangeRecord.base;
       if (!patchRange) { return false; }
@@ -274,5 +273,29 @@
       return -parseInt(rangeBase + '', 10);
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.PatchSetMixin = base =>
+      class extends base {
+        computeLatestPatchNum(allPatchSets) {}
+
+        hasEditPatchsetLoaded(patchRangeRecord) {}
+
+        hasEditBasedOnCurrentPatchSet(allPatchSets) {}
+
+        computeAllPatchSets(change) {}
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index 3db4084..0e4033e 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -19,7 +19,7 @@
 <script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <title>gr-patch-set-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="gr-patch-set-behavior.html">
@@ -170,9 +170,7 @@
           messages: [],
           work_in_progress: initialWip,
         };
-        const revs = Object.keys(tagsByRevision).sort((a, b) => {
-          return a - b;
-        });
+        const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
         for (const rev of revs) {
           for (const tag of tagsByRevision[rev]) {
             change.messages.push({
@@ -190,9 +188,7 @@
         }
         const verifier = {
           assertWip(revision, expectedWip) {
-            const patchNum = patchNums.find(patchNum => {
-              return patchNum.num == revision;
-            });
+            const patchNum = patchNums.find(patchNum => patchNum.num == revision);
             if (!patchNum) {
               assert.fail('revision ' + revision + ' not found');
             }
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
index 5e597ae..67e4ca6 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -81,6 +81,12 @@
       return path;
     },
 
+    isMagicPath(path) {
+      return !!path &&
+          (path === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH || path ===
+              Gerrit.PathListBehavior.MERGE_LIST_PATH);
+    },
+
     computeTruncatedPath(path) {
       return Gerrit.PathListBehavior.truncatePath(
           Gerrit.PathListBehavior.computeDisplayPath(path));
@@ -110,5 +116,25 @@
       return `\u2026/${pathPieces.slice(index).join('/')}`;
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.PathListMixin = base =>
+      class extends base {
+        computeDisplayPath(path) {}
+
+        computeTruncatedPath(path) {}
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
index 0046290..12b981c 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -19,7 +19,7 @@
 <script src="/bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../test/common-test-setup.html"/>
 <title>gr-path-list-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <link rel="import" href="gr-path-list-behavior.html">
@@ -56,6 +56,14 @@
       assert.equal(name('/MERGE_LIST'), 'Merge list');
     });
 
+    test('isMagicPath', () => {
+      const isMagic = Gerrit.PathListBehavior.isMagicPath;
+      assert.isFalse(isMagic(undefined));
+      assert.isFalse(isMagic('/foo.cc'));
+      assert.isTrue(isMagic('/COMMIT_MSG'));
+      assert.isTrue(isMagic('/MERGE_LIST'));
+    });
+
     test('truncatePath with long path should add ellipsis', () => {
       const truncatePath = Gerrit.PathListBehavior.truncatePath;
       let path = 'level1/level2/level3/level4/file.js';
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
index 2fa9191..69ebf23 100644
--- a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.html
@@ -34,5 +34,22 @@
     },
     PLUGIN_CONFIG_CHANGED: 'plugin-config-changed',
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.RepoPluginConfigMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index 0bf620f..19d08ff 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -49,6 +49,7 @@
       },
     },
 
+    /** @override */
     detached() {
       this._handleHideTooltip();
     },
@@ -146,4 +147,28 @@
       }
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.TooltipMixin = base =>
+      class extends base {
+        static get properties() {
+          return {
+            hasTooltip: {
+              type: Boolean,
+            },
+          };
+        }
+      };
+  }
 })(window);
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index 173c8d4..f3354a4 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -17,7 +17,7 @@
 -->
 
 <title>tooltip-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
index 64274d2..0396c4f 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.html
@@ -55,5 +55,22 @@
       return decodeURIComponent(withoutPlus);
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.URLEncodingMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
index 73e51d3..6cf2c68 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
@@ -17,7 +17,7 @@
 -->
 
 <title>gr-url-encoding-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 3c5a733..8a1ed87 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -540,6 +540,7 @@
         }
       },
 
+      /** @override */
       attached() {
         const shortcuts = shortcutManager.attachHost(this);
         if (!shortcuts) { return; }
@@ -559,6 +560,7 @@
         }
       },
 
+      /** @override */
       detached() {
         if (shortcutManager.detachHost(this)) {
           this.removeOwnKeyBindings();
@@ -612,5 +614,22 @@
       shortcutManager.bindShortcut(shortcut, ...bindings);
     },
   };
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.KeyboardShortcutMixin = base =>
+      class extends base {
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index 3183c7e..ba143ec 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index 85bc6a1..709cc8a 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -169,7 +169,7 @@
 
     /**
      * @param {!Object} change
-     * @return {String}
+     * @return {string}
      */
     changeStatusString(change) {
       return this.changeStatuses(change).join(', ');
@@ -177,5 +177,25 @@
   },
   Gerrit.BaseUrlBehavior,
   ];
+
+  // eslint-disable-next-line no-unused-vars
+  function defineEmptyMixin() {
+    // This is a temporary function.
+    // Polymer linter doesn't process correctly the following code:
+    // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
+    // To workaround this issue, the mock mixin is declared in this method.
+    // In the following changes, legacy behaviors will be converted to mixins.
+
+    /**
+     * @polymer
+     * @mixinFunction
+     */
+    Gerrit.RESTClientMixin = base =>
+      class extends base {
+        changeStatusString(change) {}
+
+        changeStatuses(change, opt_options) {}
+      };
+  }
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index a77a01f..cfcc11e 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>keyboard-shortcut-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
index ab446f1..7351647 100644
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -17,7 +17,7 @@
 -->
 
 <title>safe-types-behavior</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
index ac65360..a52cb1a 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -86,7 +86,9 @@
         padding-right: var(--spacing-m);
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <fieldset id="section"
         class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]">
       <div id="mainContainer">
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index 77e35c6..a421043 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -37,61 +37,71 @@
   const ON_BEHALF_OF = '(On Behalf Of)';
   const LABEL = 'Label';
 
-  Polymer({
-    is: 'gr-access-section',
+  /**
+   * @appliesMixin Gerrit.AccessMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrAccessSection extends Polymer.mixinBehaviors( [
+    Gerrit.AccessBehavior,
+    /**
+     * Unused in this element, but called by other elements in tests
+     * e.g gr-repo-access_test.
+     */
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-access-section'; }
 
-    properties: {
-      capabilities: Object,
-      /** @type {?} */
-      section: {
-        type: Object,
-        notify: true,
-        observer: '_updateSection',
-      },
-      groups: Object,
-      labels: Object,
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      canUpload: Boolean,
-      ownerOf: Array,
-      _originalId: String,
-      _editingRef: {
-        type: Boolean,
-        value: false,
-      },
-      _deleted: {
-        type: Boolean,
-        value: false,
-      },
-      _permissions: Array,
-    },
+    static get properties() {
+      return {
+        capabilities: Object,
+        /** @type {?} */
+        section: {
+          type: Object,
+          notify: true,
+          observer: '_updateSection',
+        },
+        groups: Object,
+        labels: Object,
+        editing: {
+          type: Boolean,
+          value: false,
+          observer: '_handleEditingChanged',
+        },
+        canUpload: Boolean,
+        ownerOf: Array,
+        _originalId: String,
+        _editingRef: {
+          type: Boolean,
+          value: false,
+        },
+        _deleted: {
+          type: Boolean,
+          value: false,
+        },
+        _permissions: Array,
+      };
+    }
 
-    behaviors: [
-      Gerrit.AccessBehavior,
-      /**
-       * Unused in this element, but called by other elements in tests
-       * e.g gr-repo-access_test.
-       */
-      Gerrit.FireBehavior,
-    ],
-
-    listeners: {
-      'access-saved': '_handleAccessSaved',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('access-saved',
+          () => this._handleAccessSaved());
+    }
 
     _updateSection(section) {
       this._permissions = this.toSortedArray(section.value.permissions);
       this._originalId = section.id;
-    },
+    }
 
     _handleAccessSaved() {
       // Set a new 'original' value to keep track of after the value has been
       // saved.
       this._updateSection(this.section);
-    },
+    }
 
     _handleValueChange() {
       if (!this.section.value.added) {
@@ -104,7 +114,7 @@
             'access-modified', {bubbles: true, composed: true}));
       }
       this.section.value.updatedId = this.section.id;
-    },
+    }
 
     _handleEditingChanged(editing, editingOld) {
       // Ignore when editing gets set initially.
@@ -124,7 +134,7 @@
           }
         }
       }
-    },
+    }
 
     _computePermissions(name, capabilities, labels) {
       let allPermissions;
@@ -138,20 +148,19 @@
         allPermissions = labelOptions.concat(
             this.toSortedArray(this.permissionValues));
       }
-      return allPermissions.filter(permission => {
-        return !this.section.value.permissions[permission.id];
-      });
-    },
+      return allPermissions
+          .filter(permission => !this.section.value.permissions[permission.id]);
+    }
 
     _computeHideEditClass(section) {
       return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
-    },
+    }
 
     _handleAddedPermissionRemoved(e) {
       const index = e.model.index;
       this._permissions = this._permissions.slice(0, index).concat(
           this._permissions.slice(index + 1, this._permissions.length));
-    },
+    }
 
     _computeLabelOptions(labels) {
       const labelOptions = [];
@@ -173,7 +182,7 @@
         });
       }
       return labelOptions;
-    },
+    }
 
     _computePermissionName(name, permission, permissionValues, capabilities) {
       if (name === GLOBAL_NAME) {
@@ -187,7 +196,7 @@
         }
         return `${LABEL} ${permission.value.label}${behalfOf}`;
       }
-    },
+    }
 
     _computeSectionName(name) {
       // When a new section is created, it doesn't yet have a ref. Set into
@@ -205,7 +214,7 @@
         return `Reference: ${name}`;
       }
       return name;
-    },
+    }
 
     _handleRemoveReference() {
       if (this.section.value.added) {
@@ -216,31 +225,32 @@
       this.section.value.deleted = true;
       this.dispatchEvent(
           new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _handleUndoRemove() {
       this._deleted = false;
       delete this.section.value.deleted;
-    },
+    }
 
     editRefInput() {
       return Polymer.dom(this.root).querySelector(Polymer.Element ?
         'iron-input.editRefInput' :
         'input[is=iron-input].editRefInput');
-    },
+    }
 
     editReference() {
       this._editingRef = true;
       this.editRefInput().focus();
-    },
+    }
 
     _isEditEnabled(canUpload, ownerOf, sectionId) {
       return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
-    },
+    }
 
     _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
       const classList = [];
-      if (editing && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
+      if (editing
+         && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
         classList.push('editing');
       }
       if (editingRef) {
@@ -250,11 +260,11 @@
         classList.push('deleted');
       }
       return classList.join(' ');
-    },
+    }
 
     _computeEditBtnClass(name) {
       return name === GLOBAL_NAME ? 'global' : '';
-    },
+    }
 
     _handleAddPermission() {
       const value = this.$.permissionSelect.value;
@@ -287,6 +297,8 @@
       this.push('_permissions', permission);
       this.set(['section.value.permissions', permission.id],
           permission.value);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccessSection.is, GrAccessSection);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
index 9c49270..e0c1f92d 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-access-section</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -188,7 +188,6 @@
         assert.isTrue(element.toSortedArray.lastCall.
             calledWithExactly(element.capabilities));
 
-
         // For everything else, include possible label values before filtering.
         name = 'refs/for/*';
         assert.deepEqual(element._computePermissions(name, element.capabilities,
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
index bf1086c..5207717 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -30,8 +30,12 @@
 
 <dom-module id="gr-admin-group-list">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <gr-list-view
         create-new="[[_createNewCapability]]"
         filter="[[_filter]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 3e087e3..96008b7 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -17,65 +17,74 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-admin-group-list',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.ListViewMixin
+   * @extends Polymer.Element
+   */
+  class GrAdminGroupList extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.ListViewBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-admin-group-list'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
 
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/groups',
-      },
-      _hasNewGroupName: Boolean,
-      _createNewCapability: {
-        type: Boolean,
-        value: false,
-      },
-      _groups: Array,
+        /**
+         * Offset of currently visible query results.
+         */
+        _offset: Number,
+        _path: {
+          type: String,
+          readOnly: true,
+          value: '/admin/groups',
+        },
+        _hasNewGroupName: Boolean,
+        _createNewCapability: {
+          type: Boolean,
+          value: false,
+        },
+        _groups: Array,
 
-      /**
-       * Because  we request one more than the groupsPerPage, _shownGroups
-       * may be one less than _groups.
-       * */
-      _shownGroups: {
-        type: Array,
-        computed: 'computeShownItems(_groups)',
-      },
+        /**
+         * Because  we request one more than the groupsPerPage, _shownGroups
+         * may be one less than _groups.
+         * */
+        _shownGroups: {
+          type: Array,
+          computed: 'computeShownItems(_groups)',
+        },
 
-      _groupsPerPage: {
-        type: Number,
-        value: 25,
-      },
+        _groupsPerPage: {
+          type: Number,
+          value: 25,
+        },
 
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-    },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _filter: String,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.ListViewBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._getCreateGroupCapability();
       this.fire('title-change', {title: 'Groups'});
       this._maybeOpenCreateOverlay(this.params);
-    },
+    }
 
     _paramsChanged(params) {
       this._loading = true;
@@ -84,7 +93,7 @@
 
       return this._getGroups(this._filter, this._groupsPerPage,
           this._offset);
-    },
+    }
 
     /**
      * Opens the create overlay if the route has a hash 'create'
@@ -95,11 +104,11 @@
       if (params && params.openCreateModal) {
         this.$.createOverlay.open();
       }
-    },
+    }
 
     _computeGroupUrl(id) {
       return Gerrit.Nav.getUrlForGroup(id);
-    },
+    }
 
     _getCreateGroupCapability() {
       return this.$.restAPI.getAccount().then(account => {
@@ -111,7 +120,7 @@
               }
             });
       });
-    },
+    }
 
     _getGroups(filter, groupsPerPage, offset) {
       this._groups = [];
@@ -128,30 +137,32 @@
                 });
             this._loading = false;
           });
-    },
+    }
 
     _refreshGroupsList() {
       this.$.restAPI.invalidateGroupsCache();
       return this._getGroups(this._filter, this._groupsPerPage,
           this._offset);
-    },
+    }
 
     _handleCreateGroup() {
       this.$.createNewModal.handleCreateGroup().then(() => {
         this._refreshGroupsList();
       });
-    },
+    }
 
     _handleCloseCreate() {
       this.$.createOverlay.close();
-    },
+    }
 
     _handleCreateClicked() {
       this.$.createOverlay.open();
-    },
+    }
 
     _visibleToAll(item) {
       return item.options.visible_to_all === true ? 'Y' : 'N';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAdminGroupList.is, GrAdminGroupList);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
index 58c7be4..51fa2e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-group-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -127,9 +127,10 @@
 
     suite('filter', () => {
       test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getGroups', () => {
-          return Promise.resolve(groups);
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'getGroups',
+            () => Promise.resolve(groups));
         const value = {
           filter: 'test',
           offset: 25,
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index c7187a9..aae11d3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -43,8 +43,12 @@
 
 <dom-module id="gr-admin-view">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-menu-page-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-menu-page-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-page-nav-styles">
       gr-dropdown-list {
         --trigger-style: {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index 72cca9e..e300c90 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -17,66 +17,77 @@
 (function() {
   'use strict';
 
-
   const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-  Polymer({
-    is: 'gr-admin-view',
+  /**
+   * @appliesMixin Gerrit.AdminNavMixin
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrAdminView extends Polymer.mixinBehaviors( [
+    Gerrit.AdminNavBehavior,
+    Gerrit.BaseUrlBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-admin-view'; }
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      params: Object,
-      path: String,
-      adminView: String,
+        params: Object,
+        path: String,
+        adminView: String,
 
-      _breadcrumbParentName: String,
-      _repoName: String,
-      _groupId: {
-        type: Number,
-        observer: '_computeGroupName',
-      },
-      _groupIsInternal: Boolean,
-      _groupName: String,
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _subsectionLinks: Array,
-      _filteredLinks: Array,
-      _showDownload: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _showGroup: Boolean,
-      _showGroupAuditLog: Boolean,
-      _showGroupList: Boolean,
-      _showGroupMembers: Boolean,
-      _showRepoAccess: Boolean,
-      _showRepoCommands: Boolean,
-      _showRepoDashboards: Boolean,
-      _showRepoDetailList: Boolean,
-      _showRepoMain: Boolean,
-      _showRepoList: Boolean,
-      _showPluginList: Boolean,
-    },
+        _breadcrumbParentName: String,
+        _repoName: String,
+        _groupId: {
+          type: Number,
+          observer: '_computeGroupName',
+        },
+        _groupIsInternal: Boolean,
+        _groupName: String,
+        _groupOwner: {
+          type: Boolean,
+          value: false,
+        },
+        _subsectionLinks: Array,
+        _filteredLinks: Array,
+        _showDownload: {
+          type: Boolean,
+          value: false,
+        },
+        _isAdmin: {
+          type: Boolean,
+          value: false,
+        },
+        _showGroup: Boolean,
+        _showGroupAuditLog: Boolean,
+        _showGroupList: Boolean,
+        _showGroupMembers: Boolean,
+        _showRepoAccess: Boolean,
+        _showRepoCommands: Boolean,
+        _showRepoDashboards: Boolean,
+        _showRepoDetailList: Boolean,
+        _showRepoMain: Boolean,
+        _showRepoList: Boolean,
+        _showPluginList: Boolean,
+      };
+    }
 
-    behaviors: [
-      Gerrit.AdminNavBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+    static get observers() {
+      return [
+        '_paramsChanged(params)',
+      ];
+    }
 
-    observers: [
-      '_paramsChanged(params)',
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.reload();
-    },
+    }
 
     reload() {
       const promises = [
@@ -124,18 +135,18 @@
                   });
             });
       });
-    },
+    }
 
     _computeSelectValue(params) {
       if (!params || !params.view) { return; }
       return params.view + (params.detail || '');
-    },
+    }
 
     _selectedIsCurrentPage(selected) {
       return (selected.parent === (this._repoName || this._groupId) &&
           selected.view === this.params.view &&
           selected.detailType === this.params.detail);
-    },
+    }
 
     _handleSubsectionChange(e) {
       const selected = this._subsectionLinks
@@ -146,7 +157,7 @@
         return;
       }
       Gerrit.Nav.navigateToRelativeUrl(selected.url);
-    },
+    }
 
     _paramsChanged(params) {
       const isGroupView = params.view === Gerrit.Nav.View.GROUP;
@@ -195,19 +206,19 @@
       }
       if (!needsReload) { return; }
       this.reload();
-    },
+    }
 
     // TODO (beckysiegel): Update these functions after router abstraction is
     // updated. They are currently copied from gr-dropdown (and should be
     // updated there as well once complete).
     _computeURLHelper(host, path) {
       return '//' + host + this.getBaseUrl() + path;
-    },
+    }
 
     _computeRelativeURL(path) {
       const host = window.location.host;
       return this._computeURLHelper(host, path);
-    },
+    }
 
     _computeLinkURL(link) {
       if (!link || typeof link.url === 'undefined') { return ''; }
@@ -215,7 +226,7 @@
         return link.url;
       }
       return this._computeRelativeURL(link.url);
-    },
+    }
 
     /**
      * @param {string} itemView
@@ -245,7 +256,7 @@
         return '';
       }
       return itemView === params.adminView ? 'selected' : '';
-    },
+    }
 
     _computeGroupName(groupId) {
       if (!groupId) { return ''; }
@@ -271,11 +282,13 @@
           this.reload();
         });
       });
-    },
+    }
 
     _updateGroupName(e) {
       this._groupName = e.detail.name;
       this.reload();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAdminView.is, GrAdminView);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index 984be19..7b0c723 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -101,13 +101,15 @@
       sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
         name: 'test-user',
       }));
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          })
+      );
       element.reload().then(() => {
         assert.equal(element._filteredLinks.length, 3);
 
@@ -127,9 +129,11 @@
       sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
         name: 'test-user',
       }));
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({})
+      );
       element.reload().then(() => {
         assert.equal(element._filteredLinks.length, 2);
 
@@ -185,19 +189,23 @@
       sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
         name: 'test-user',
       }));
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
       element.reload().then(() => {
         flushAsynchronousOperations();
         assert.equal(Polymer.dom(element.root)
             .querySelectorAll('.sectionTitle').length, 3);
         assert.equal(element.$$('.breadcrumbText').innerText, 'Test Repo');
-        assert.equal(element.$$('#pageSelect').items.length, 6);
+        assert.equal(
+            element.shadowRoot.querySelector('#pageSelect').items.length,
+            6
+        );
         done();
       });
     });
@@ -211,13 +219,14 @@
       sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
         name: 'test-user',
       }));
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
       element.reload().then(() => {
         flushAsynchronousOperations();
         assert.equal(element._filteredLinks.length, 3);
@@ -236,16 +245,18 @@
     });
 
     test('Nav is reloaded when repo changes', () => {
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
-      sandbox.stub(element.$.restAPI, 'getAccount', () => {
-        return Promise.resolve({_id: 1});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccount',
+          () => Promise.resolve({_id: 1}));
       sandbox.stub(element, 'reload');
       element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
       assert.equal(element.reload.callCount, 1);
@@ -256,16 +267,18 @@
 
     test('Nav is reloaded when group changes', () => {
       sandbox.stub(element, '_computeGroupName');
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
-      sandbox.stub(element.$.restAPI, 'getAccount', () => {
-        return Promise.resolve({_id: 1});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccount',
+          () => Promise.resolve({_id: 1}));
       sandbox.stub(element, 'reload');
       element.params = {groupId: '1', adminView: 'gr-group'};
       assert.equal(element.reload.callCount, 1);
@@ -301,7 +314,9 @@
       assert.isOk(element.$$('.mainHeader'));
       element._subsectionLinks = undefined;
       flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.$$('.mainHeader')).display, 'none');
+      assert.equal(
+          getComputedStyle(element.$$('.mainHeader')).display,
+          'none');
     });
 
     test('Dropdown only triggers navigation on explicit select', done => {
@@ -311,16 +326,18 @@
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.ACCESS,
       };
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
-      sandbox.stub(element.$.restAPI, 'getAccount', () => {
-        return Promise.resolve({_id: 1});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccountCapabilities',
+          () => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
+      sandbox.stub(
+          element.$.restAPI,
+          'getAccount',
+          () => Promise.resolve({_id: 1}));
       flushAsynchronousOperations();
       const expectedFilteredLinks = [
         {
@@ -439,13 +456,16 @@
       element.reload().then(() => {
         assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
         assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-        assert.equal(element.$$('#pageSelect').value, 'repoaccess');
+        assert.equal(
+            element.shadowRoot.querySelector('#pageSelect').value,
+            'repoaccess'
+        );
         assert.isTrue(element._selectedIsCurrentPage.calledOnce);
         // Doesn't trigger navigation from the page select menu.
         assert.isFalse(Gerrit.Nav.navigateToRelativeUrl.called);
 
         // When explicitly changed, navigation is called
-        element.$$('#pageSelect').value = 'repo';
+        element.shadowRoot.querySelector('#pageSelect').value = 'repo';
         assert.isTrue(element._selectedIsCurrentPage.calledTwice);
         assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.calledOnce);
         done();
@@ -469,16 +489,18 @@
 
     suite('_computeSelectedClass', () => {
       setup(() => {
-        sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
-          return Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          });
-        });
-        sandbox.stub(element.$.restAPI, 'getAccount', () => {
-          return Promise.resolve({_id: 1});
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'getAccountCapabilities',
+            () => Promise.resolve({
+              createGroup: true,
+              createProject: true,
+              viewPlugins: true,
+            }));
+        sandbox.stub(
+            element.$.restAPI,
+            'getAccount',
+            () => Promise.resolve({_id: 1}));
 
         return element.reload();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
index acc76de..3fde410 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -23,9 +23,16 @@
     TAGS: 'tags',
   };
 
-  Polymer({
-    is: 'gr-confirm-delete-item-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmDeleteItemDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-delete-item-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -38,26 +45,24 @@
      * @event cancel
      */
 
-    properties: {
-      item: String,
-      itemType: String,
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    static get properties() {
+      return {
+        item: String,
+        itemType: String,
+      };
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
+    }
 
     _computeItemName(detailType) {
       if (detailType === DETAIL_TYPES.BRANCHES) {
@@ -67,6 +72,9 @@
       } else if (detailType === DETAIL_TYPES.ID) {
         return 'ID';
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmDeleteItemDialog.is,
+      GrConfirmDeleteItemDialog);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
index 3292cec..c83349b 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-delete-item-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index 2a95991..1d6e706 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -31,7 +31,9 @@
 
 <dom-module id="gr-create-change-dialog">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       input:not([type="checkbox"]),
       gr-autocomplete,
@@ -41,11 +43,6 @@
       .value {
         width: 32em;
       }
-      gr-autocomplete {
-        --gr-autocomplete: {
-          padding: 0 var(--spacing-xs);
-        }
-      }
       .hide {
         display: none;
       }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index e29e5f8..3b85304 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -20,44 +20,54 @@
   const SUGGESTIONS_LIMIT = 15;
   const REF_PREFIX = 'refs/heads/';
 
-  Polymer({
-    is: 'gr-create-change-dialog',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrCreateChangeDialog extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    /**
+     * Unused in this element, but called by other elements in tests
+     * e.g gr-repo-commands_test.
+     */
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-create-change-dialog'; }
 
-    properties: {
-      repoName: String,
-      branch: String,
-      /** @type {?} */
-      _repoConfig: Object,
-      subject: String,
-      topic: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getRepoBranchesSuggestions.bind(this);
+    static get properties() {
+      return {
+        repoName: String,
+        branch: String,
+        /** @type {?} */
+        _repoConfig: Object,
+        subject: String,
+        topic: String,
+        _query: {
+          type: Function,
+          value() {
+            return this._getRepoBranchesSuggestions.bind(this);
+          },
         },
-      },
-      baseChange: String,
-      baseCommit: String,
-      privateByDefault: String,
-      canCreate: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-      _privateChangesEnabled: Boolean,
-    },
+        baseChange: String,
+        baseCommit: String,
+        privateByDefault: String,
+        canCreate: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
+        _privateChangesEnabled: Boolean,
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      /**
-       * Unused in this element, but called by other elements in tests
-       * e.g gr-repo-commands_test.
-       */
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       if (!this.repoName) { return Promise.resolve(); }
 
       const promises = [];
@@ -75,19 +85,21 @@
       }));
 
       return Promise.all(promises);
-    },
+    }
 
-    observers: [
-      '_allowCreate(branch, subject)',
-    ],
+    static get observers() {
+      return [
+        '_allowCreate(branch, subject)',
+      ];
+    }
 
     _computeBranchClass(baseChange) {
       return baseChange ? 'hide' : '';
-    },
+    }
 
     _allowCreate(branch, subject) {
       this.canCreate = !!branch && !!subject;
-    },
+    }
 
     handleCreateChange() {
       const isPrivate = this.$.privateChangeCheckBox.checked;
@@ -99,7 +111,7 @@
             if (!changeCreated) { return; }
             Gerrit.Nav.navigateToChange(changeCreated);
           });
-    },
+    }
 
     _getRepoBranchesSuggestions(input) {
       if (input.startsWith(REF_PREFIX)) {
@@ -122,7 +134,7 @@
         }
         return branches;
       });
-    },
+    }
 
     _formatBooleanString(config) {
       if (config && config.configured_value === 'TRUE') {
@@ -138,10 +150,12 @@
       } else {
         return false;
       }
-    },
+    }
 
     _computePrivateSectionClass(config) {
       return config ? 'hide' : '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreateChangeDialog.is, GrCreateChangeDialog);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index 3a3683f..67c8537 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -81,9 +81,7 @@
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
-          'createChange', () => {
-            return Promise.resolve({});
-          });
+          'createChange', () => Promise.resolve({}));
 
       element.branch = 'test-branch';
       element.topic = 'test-topic';
@@ -109,9 +107,7 @@
         configured_value: 'TRUE',
         inherited_value: false,
       };
-      sandbox.stub(element, '_formatBooleanString', () => {
-        return Promise.resolve(true);
-      });
+      sandbox.stub(element, '_formatBooleanString', () => Promise.resolve(true));
       flushAsynchronousOperations();
 
       const configInputObj = {
@@ -123,9 +119,7 @@
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
-          'createChange', () => {
-            return Promise.resolve({});
-          });
+          'createChange', () => Promise.resolve({}));
 
       element.branch = 'test-branch';
       element.topic = 'test-topic';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
index 8a4287b..d0a1fca 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
@@ -26,7 +26,9 @@
 
 <dom-module id="gr-create-group-dialog">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       :host {
         display: inline-block;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
index 01aeb43..8a4edab 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -17,40 +17,49 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-create-group-dialog',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrCreateGroupDialog extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-create-group-dialog'; }
 
-    properties: {
-      params: Object,
-      hasNewGroupName: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-      _name: Object,
-      _groupCreated: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        params: Object,
+        hasNewGroupName: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
+        _name: Object,
+        _groupCreated: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    observers: [
-      '_updateGroupName(_name)',
-    ],
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+    static get observers() {
+      return [
+        '_updateGroupName(_name)',
+      ];
+    }
 
     _computeGroupUrl(groupId) {
       return this.getBaseUrl() + '/admin/groups/' +
           this.encodeURL(groupId, true);
-    },
+    }
 
     _updateGroupName(name) {
       this.hasNewGroupName = !!name;
-    },
+    }
 
     handleCreateGroup() {
       return this.$.restAPI.createGroup({name: this._name})
@@ -62,6 +71,8 @@
                   page.show(this._computeGroupUrl(group.group_id));
                 });
           });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreateGroupDialog.is, GrCreateGroupDialog);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
index ebca289..3a99526 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-group-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -56,7 +56,8 @@
     test('name is updated correctly', done => {
       assert.isFalse(element.hasNewGroupName);
 
-      ironInput(element.root).bindValue = GROUP_NAME;
+      const inputEl = element.root.querySelector('iron-input');
+      inputEl.bindValue = GROUP_NAME;
 
       setTimeout(() => {
         assert.isTrue(element.hasNewGroupName);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
index ea5b84b..5a9213b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
@@ -28,7 +28,9 @@
 
 <dom-module id="gr-create-pointer-dialog">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       :host {
         display: inline-block;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index 4e9da90..2d6b4aa 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -22,35 +22,44 @@
     tags: 'tags',
   };
 
-  Polymer({
-    is: 'gr-create-pointer-dialog',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrCreatePointerDialog extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-create-pointer-dialog'; }
 
-    properties: {
-      detailType: String,
-      repoName: String,
-      hasNewItemName: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-      itemDetail: String,
-      _itemName: String,
-      _itemRevision: String,
-      _itemAnnotation: String,
-    },
+    static get properties() {
+      return {
+        detailType: String,
+        repoName: String,
+        hasNewItemName: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
+        itemDetail: String,
+        _itemName: String,
+        _itemRevision: String,
+        _itemAnnotation: String,
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    observers: [
-      '_updateItemName(_itemName)',
-    ],
+    static get observers() {
+      return [
+        '_updateItemName(_itemName)',
+      ];
+    }
 
     _updateItemName(name) {
       this.hasNewItemName = !!name;
-    },
+    }
 
     _computeItemUrl(project) {
       if (this.itemDetail === DETAIL_TYPES.branches) {
@@ -60,7 +69,7 @@
         return this.getBaseUrl() + '/admin/repos/' +
             this.encodeURL(this.repoName, true) + ',tags';
       }
-    },
+    }
 
     handleCreateItem() {
       const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
@@ -82,10 +91,12 @@
               }
             });
       }
-    },
+    }
 
     _computeHideItemClass(type) {
       return type === DETAIL_TYPES.branches ? 'hideItem' : '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreatePointerDialog.is, GrCreatePointerDialog);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
index 08e8213..43d5fbe 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-pointer-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -39,6 +39,10 @@
     let element;
     let sandbox;
 
+    const ironInput = function(element) {
+      return Polymer.dom(element).querySelector('iron-input');
+    };
+
     setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
@@ -52,9 +56,10 @@
     });
 
     test('branch created', done => {
-      sandbox.stub(element.$.restAPI, 'createRepoBranch', () => {
-        return Promise.resolve({});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'createRepoBranch',
+          () => Promise.resolve({}));
 
       assert.isFalse(element.hasNewItemName);
 
@@ -73,9 +78,10 @@
     });
 
     test('tag created', done => {
-      sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
-        return Promise.resolve({});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'createRepoTag',
+          () => Promise.resolve({}));
 
       assert.isFalse(element.hasNewItemName);
 
@@ -94,9 +100,10 @@
     });
 
     test('tag created with annotations', done => {
-      sandbox.stub(element.$.restAPI, 'createRepoTag', () => {
-        return Promise.resolve({});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'createRepoTag',
+          () => Promise.resolve({}));
 
       assert.isFalse(element.hasNewItemName);
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
index d1a2471..b78090c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -29,7 +29,9 @@
 
 <dom-module id="gr-create-repo-dialog">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       :host {
         display: inline-block;
@@ -38,14 +40,7 @@
         width: 20em;
       }
       gr-autocomplete {
-        border: none;
-        --gr-autocomplete: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          height: 2em;
-          padding: 0 var(--spacing-xs);
-          width: 20em;
-        }
+        width: 20em;
       }
     </style>
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index bb2b5f2..290f025 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -17,69 +17,78 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-create-repo-dialog',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrCreateRepoDialog extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-create-repo-dialog'; }
 
-    properties: {
-      params: Object,
-      hasNewRepoName: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
+    static get properties() {
+      return {
+        params: Object,
+        hasNewRepoName: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
 
-      /** @type {?} */
-      _repoConfig: {
-        type: Object,
-        value: () => {
+        /** @type {?} */
+        _repoConfig: {
+          type: Object,
+          value: () => {
           // Set default values for dropdowns.
-          return {
-            create_empty_commit: true,
-            permissions_only: false,
-          };
+            return {
+              create_empty_commit: true,
+              permissions_only: false,
+            };
+          },
         },
-      },
-      _repoCreated: {
-        type: Boolean,
-        value: false,
-      },
-      _repoOwner: String,
-      _repoOwnerId: {
-        type: String,
-        observer: '_repoOwnerIdUpdate',
-      },
-
-      _query: {
-        type: Function,
-        value() {
-          return this._getRepoSuggestions.bind(this);
+        _repoCreated: {
+          type: Boolean,
+          value: false,
         },
-      },
-      _queryGroups: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
+        _repoOwner: String,
+        _repoOwnerId: {
+          type: String,
+          observer: '_repoOwnerIdUpdate',
         },
-      },
-    },
 
-    observers: [
-      '_updateRepoName(_repoConfig.name)',
-    ],
+        _query: {
+          type: Function,
+          value() {
+            return this._getRepoSuggestions.bind(this);
+          },
+        },
+        _queryGroups: {
+          type: Function,
+          value() {
+            return this._getGroupSuggestions.bind(this);
+          },
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+    static get observers() {
+      return [
+        '_updateRepoName(_repoConfig.name)',
+      ];
+    }
 
     _computeRepoUrl(repoName) {
       return this.getBaseUrl() + '/admin/repos/' +
           this.encodeURL(repoName, true);
-    },
+    }
 
     _updateRepoName(name) {
       this.hasNewRepoName = !!name;
-    },
+    }
 
     _repoOwnerIdUpdate(id) {
       if (id) {
@@ -87,7 +96,7 @@
       } else {
         this.set('_repoConfig.owners', undefined);
       }
-    },
+    }
 
     handleCreateRepo() {
       return this.$.restAPI.createRepo(this._repoConfig)
@@ -97,7 +106,7 @@
               page.show(this._computeRepoUrl(this._repoConfig.name));
             }
           });
-    },
+    }
 
     _getRepoSuggestions(input) {
       return this.$.restAPI.getSuggestedProjects(input)
@@ -112,7 +121,7 @@
             }
             return repos;
           });
-    },
+    }
 
     _getGroupSuggestions(input) {
       return this.$.restAPI.getSuggestedGroups(input)
@@ -127,6 +136,8 @@
             }
             return groups;
           });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreateRepoDialog.is, GrCreateRepoDialog);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
index 7e32c5c..80f953b 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-repo-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -66,9 +66,7 @@
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
-          'createRepo', () => {
-            return Promise.resolve({});
-          });
+          'createRepo', () => Promise.resolve({}));
 
       assert.isFalse(element.hasNewRepoName);
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
index c15f091..4ed751d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
@@ -27,7 +27,9 @@
 
 <dom-module id="gr-group-audit-log">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-table-styles">
       /* GenericList style centers the last column, but we don't want that here. */
       .genericList tr th:last-of-type,
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
index 8901d4a..11517d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -19,30 +19,41 @@
 
   const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
-  Polymer({
-    is: 'gr-group-audit-log',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.ListViewMixin
+   * @extends Polymer.Element
+   */
+  class GrGroupAuditLog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.ListViewBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-group-audit-log'; }
 
-    properties: {
-      groupId: String,
-      _auditLog: Array,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-    },
+    static get properties() {
+      return {
+        groupId: String,
+        _auditLog: Array,
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.ListViewBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.fire('title-change', {title: 'Audit Log'});
-    },
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       this._getAuditLogs();
-    },
+    }
 
     _getAuditLogs() {
       if (!this.groupId) { return ''; }
@@ -60,11 +71,11 @@
             this._auditLog = auditLog;
             this._loading = false;
           });
-    },
+    }
 
     _status(item) {
       return item.disabled ? 'Disabled' : 'Enabled';
-    },
+    }
 
     itemType(type) {
       let item;
@@ -81,11 +92,11 @@
           item = '';
       }
       return item;
-    },
+    }
 
     _isGroupEvent(type) {
       return GROUP_EVENTS.indexOf(type) !== -1;
-    },
+    }
 
     _computeGroupUrl(group) {
       if (group && group.url && group.id) {
@@ -93,11 +104,11 @@
       }
 
       return '';
-    },
+    }
 
     _getIdForUser(account) {
       return account._account_id ? ' (' + account._account_id + ')' : '';
-    },
+    }
 
     _getNameForGroup(group) {
       if (group && group.name) {
@@ -108,6 +119,8 @@
       }
 
       return '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrGroupAuditLog.is, GrGroupAuditLog);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
index 313d465..d517242 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-audit-log</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index 86f66c4..cf24793 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -33,19 +33,21 @@
 
 <dom-module id="gr-group-members">
   <template>
-    <style include="gr-form-styles"></style>
-    <style include="gr-table-styles"></style>
-    <style include="gr-subpage-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-subpage-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       .input {
         width: 15em;
       }
       gr-autocomplete {
         width: 20em;
-        --gr-autocomplete: {
-          height: 2em;
-          width: 20em;
-        }
       }
       a {
         color: var(--primary-text-color);
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index 7f8e9ac..8c29f73 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -23,57 +23,67 @@
 
   const URL_REGEX = '^(?:[a-z]+:)?//';
 
-  Polymer({
-    is: 'gr-group-members',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrGroupMembers extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-group-members'; }
 
-    properties: {
-      groupId: Number,
-      _groupMemberSearchId: String,
-      _groupMemberSearchName: String,
-      _includedGroupSearchId: String,
-      _includedGroupSearchName: String,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _groupName: String,
-      _groupMembers: Object,
-      _includedGroups: Object,
-      _itemName: String,
-      _itemType: String,
-      _queryMembers: {
-        type: Function,
-        value() {
-          return this._getAccountSuggestions.bind(this);
+    static get properties() {
+      return {
+        groupId: Number,
+        _groupMemberSearchId: String,
+        _groupMemberSearchName: String,
+        _includedGroupSearchId: String,
+        _includedGroupSearchName: String,
+        _loading: {
+          type: Boolean,
+          value: true,
         },
-      },
-      _queryIncludedGroup: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
+        _groupName: String,
+        _groupMembers: Object,
+        _includedGroups: Object,
+        _itemName: String,
+        _itemType: String,
+        _queryMembers: {
+          type: Function,
+          value() {
+            return this._getAccountSuggestions.bind(this);
+          },
         },
-      },
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        _queryIncludedGroup: {
+          type: Function,
+          value() {
+            return this._getGroupSuggestions.bind(this);
+          },
+        },
+        _groupOwner: {
+          type: Boolean,
+          value: false,
+        },
+        _isAdmin: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._loadGroupDetails();
 
       this.fire('title-change', {title: 'Members'});
-    },
+    }
 
     _loadGroupDetails() {
       if (!this.groupId) { return; }
@@ -113,15 +123,15 @@
               this._loading = false;
             });
           });
-    },
+    }
 
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
-    },
+    }
 
     _isLoading() {
       return this._loading || this._loading === undefined;
-    },
+    }
 
     _computeGroupUrl(url) {
       if (!url) { return; }
@@ -136,7 +146,7 @@
         return this.getBaseUrl() + url.slice(1);
       }
       return this.getBaseUrl() + url;
-    },
+    }
 
     _handleSavingGroupMember() {
       return this.$.restAPI.saveGroupMembers(this._groupName,
@@ -150,7 +160,7 @@
         this._groupMemberSearchName = '';
         this._groupMemberSearchId = '';
       });
-    },
+    }
 
     _handleDeleteConfirm() {
       this.$.overlay.close();
@@ -177,11 +187,11 @@
               }
             });
       }
-    },
+    }
 
     _handleConfirmDialogCancel() {
       this.$.overlay.close();
-    },
+    }
 
     _handleDeleteMember(e) {
       const id = e.model.get('item._account_id');
@@ -196,7 +206,7 @@
       this._itemId = id;
       this._itemType = 'member';
       this.$.overlay.open();
-    },
+    }
 
     _handleSavingIncludedGroups() {
       return this.$.restAPI.saveIncludedGroup(this._groupName,
@@ -222,7 +232,7 @@
             this._includedGroupSearchName = '';
             this._includedGroupSearchId = '';
           });
-    },
+    }
 
     _handleDeleteIncludedGroup(e) {
       const id = decodeURIComponent(e.model.get('item.id')).replace(/\+/g, ' ');
@@ -233,7 +243,7 @@
       this._itemId = id;
       this._itemType = 'includedGroup';
       this.$.overlay.open();
-    },
+    }
 
     _getAccountSuggestions(input) {
       if (input.length === 0) { return Promise.resolve([]); }
@@ -257,7 +267,7 @@
         }
         return accountSuggestions;
       });
-    },
+    }
 
     _getGroupSuggestions(input) {
       return this.$.restAPI.getSuggestedGroups(input)
@@ -272,10 +282,12 @@
             }
             return groups;
           });
-    },
+    }
 
     _computeHideItemClass(owner, admin) {
       return admin || owner ? '' : 'canModify';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrGroupMembers.is, GrGroupMembers);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
index bf9113b..1df2a41 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-members</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -153,9 +153,10 @@
       element = fixture('basic');
       sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
       element.groupId = 1;
-      groupStub = sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve(groups);
-      });
+      groupStub = sandbox.stub(
+          element.$.restAPI,
+          'getGroupConfig',
+          () => Promise.resolve(groups));
       return element._loadGroupDetails();
     });
 
@@ -181,9 +182,7 @@
       const memberName = 'test-admin';
 
       const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-          () => {
-            return Promise.resolve({});
-          });
+          () => Promise.resolve({}));
 
       const button = element.$.saveGroupMember;
 
@@ -208,9 +207,7 @@
       const includedGroupName = 'testName';
 
       const saveIncludedGroupStub = sandbox.stub(
-          element.$.restAPI, 'saveIncludedGroup', () => {
-            return Promise.resolve({});
-          });
+          element.$.restAPI, 'saveIncludedGroup', () => Promise.resolve({}));
 
       const button = element.$.saveIncludedGroups;
 
@@ -248,34 +245,42 @@
       });
     });
 
-    test('_getAccountSuggestions empty', () => {
-      return element._getAccountSuggestions('nonexistent').then(accounts => {
-        assert.equal(accounts.length, 0);
-      });
+    test('_getAccountSuggestions empty', done => {
+      element
+          ._getAccountSuggestions('nonexistent').then(accounts => {
+            assert.equal(accounts.length, 0);
+            done();
+          });
     });
 
-    test('_getAccountSuggestions non-empty', () => {
-      return element._getAccountSuggestions('test-').then(accounts => {
-        assert.equal(accounts.length, 3);
-        assert.equal(accounts[0].name,
-            'test-account <test.account@example.com>');
-        assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
-        assert.equal(accounts[2].name, 'test-git');
-      });
+    test('_getAccountSuggestions non-empty', done => {
+      element
+          ._getAccountSuggestions('test-').then(accounts => {
+            assert.equal(accounts.length, 3);
+            assert.equal(accounts[0].name,
+                'test-account <test.account@example.com>');
+            assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+            assert.equal(accounts[2].name, 'test-git');
+            done();
+          });
     });
 
-    test('_getGroupSuggestions empty', () => {
-      return element._getGroupSuggestions('nonexistent').then(groups => {
-        assert.equal(groups.length, 0);
-      });
+    test('_getGroupSuggestions empty', done => {
+      element
+          ._getGroupSuggestions('nonexistent').then(groups => {
+            assert.equal(groups.length, 0);
+            done();
+          });
     });
 
-    test('_getGroupSuggestions non-empty', () => {
-      return element._getGroupSuggestions('test').then(groups => {
-        assert.equal(groups.length, 2);
-        assert.equal(groups[0].name, 'test-admin');
-        assert.equal(groups[1].name, 'test/Administrator (admin)');
-      });
+    test('_getGroupSuggestions non-empty', done => {
+      element
+          ._getGroupSuggestions('test').then(groups => {
+            assert.equal(groups.length, 2);
+            assert.equal(groups[0].name, 'test-admin');
+            assert.equal(groups[1].name, 'test/Administrator (admin)');
+            done();
+          });
     });
 
     test('_computeHideItemClass returns string for admin', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
index 19879cb..faabe84 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -28,7 +28,9 @@
 
 <dom-module id="gr-group">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-subpage-styles">
       h3.edited:after {
         color: var(--deemphasized-text-color);
@@ -38,7 +40,9 @@
         margin-top: var(--spacing-s);
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <main class="gr-form-styles read-only">
       <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
         Loading...
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
index 19cb45c..42846f4 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -30,78 +30,87 @@
     },
   };
 
-  Polymer({
-    is: 'gr-group',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrGroup extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-group'; }
     /**
      * Fired when the group name changes.
      *
      * @event name-changed
      */
 
-    properties: {
-      groupId: Number,
-      _rename: {
-        type: Boolean,
-        value: false,
-      },
-      _groupIsInternal: Boolean,
-      _description: {
-        type: Boolean,
-        value: false,
-      },
-      _owner: {
-        type: Boolean,
-        value: false,
-      },
-      _options: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?} */
-      _groupConfig: Object,
-      _groupConfigOwner: String,
-      _groupName: Object,
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(OPTIONS);
+    static get properties() {
+      return {
+        groupId: Number,
+        _rename: {
+          type: Boolean,
+          value: false,
         },
-      },
-      _query: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
+        _groupIsInternal: Boolean,
+        _description: {
+          type: Boolean,
+          value: false,
         },
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        _owner: {
+          type: Boolean,
+          value: false,
+        },
+        _options: {
+          type: Boolean,
+          value: false,
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        /** @type {?} */
+        _groupConfig: Object,
+        _groupConfigOwner: String,
+        _groupName: Object,
+        _groupOwner: {
+          type: Boolean,
+          value: false,
+        },
+        _submitTypes: {
+          type: Array,
+          value() {
+            return Object.values(OPTIONS);
+          },
+        },
+        _query: {
+          type: Function,
+          value() {
+            return this._getGroupSuggestions.bind(this);
+          },
+        },
+        _isAdmin: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    static get observers() {
+      return [
+        '_handleConfigName(_groupConfig.name)',
+        '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
+        '_handleConfigDescription(_groupConfig.description)',
+        '_handleConfigOptions(_groupConfig.options.visible_to_all)',
+      ];
+    }
 
-    observers: [
-      '_handleConfigName(_groupConfig.name)',
-      '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
-      '_handleConfigDescription(_groupConfig.description)',
-      '_handleConfigOptions(_groupConfig.options.visible_to_all)',
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._loadGroup();
-    },
+    }
 
     _loadGroup() {
       if (!this.groupId) { return; }
@@ -143,15 +152,15 @@
               this._loading = false;
             });
           });
-    },
+    }
 
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
-    },
+    }
 
     _isLoading() {
       return this._loading || this._loading === undefined;
-    },
+    }
 
     _handleSaveName() {
       return this.$.restAPI.saveGroupName(this.groupId, this._groupConfig.name)
@@ -163,7 +172,7 @@
               this._rename = false;
             }
           });
-    },
+    }
 
     _handleSaveOwner() {
       let owner = this._groupConfig.owner;
@@ -174,14 +183,14 @@
           owner).then(config => {
         this._owner = false;
       });
-    },
+    }
 
     _handleSaveDescription() {
       return this.$.restAPI.saveGroupDescription(this.groupId,
           this._groupConfig.description).then(config => {
         this._description = false;
       });
-    },
+    }
 
     _handleSaveOptions() {
       const visible = this._groupConfig.options.visible_to_all;
@@ -192,31 +201,31 @@
           options).then(config => {
         this._options = false;
       });
-    },
+    }
 
     _handleConfigName() {
       if (this._isLoading()) { return; }
       this._rename = true;
-    },
+    }
 
     _handleConfigOwner() {
       if (this._isLoading()) { return; }
       this._owner = true;
-    },
+    }
 
     _handleConfigDescription() {
       if (this._isLoading()) { return; }
       this._description = true;
-    },
+    }
 
     _handleConfigOptions() {
       if (this._isLoading()) { return; }
       this._options = true;
-    },
+    }
 
     _computeHeaderClass(configChanged) {
       return configChanged ? 'edited' : '';
-    },
+    }
 
     _getGroupSuggestions(input) {
       return this.$.restAPI.getSuggestedGroups(input)
@@ -231,10 +240,12 @@
             }
             return groups;
           });
-    },
+    }
 
     _computeGroupDisabled(owner, admin, groupIsInternal) {
       return groupIsInternal && (admin || owner) ? false : true;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrGroup.is, GrGroup);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
index 1672e85..5534cde 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -56,9 +56,11 @@
         getLoggedIn() { return Promise.resolve(true); },
       });
       element = fixture('basic');
-      groupStub = sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve(group);
-      });
+      groupStub = sandbox.stub(
+          element.$.restAPI,
+          'getGroupConfig',
+          () => Promise.resolve(group)
+      );
     });
 
     teardown(() => {
@@ -74,9 +76,10 @@
     });
 
     test('default values are populated with internal group', done => {
-      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
-        return Promise.resolve(true);
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getIsGroupOwner',
+          () => Promise.resolve(true));
       element.groupId = 1;
       element._loadGroup().then(() => {
         assert.isTrue(element._groupIsInternal);
@@ -89,12 +92,14 @@
       const groupExternal = Object.assign({}, group);
       groupExternal.id = 'external-group-id';
       groupStub.restore();
-      groupStub = sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve(groupExternal);
-      });
-      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
-        return Promise.resolve(true);
-      });
+      groupStub = sandbox.stub(
+          element.$.restAPI,
+          'getGroupConfig',
+          () => Promise.resolve(groupExternal));
+      sandbox.stub(
+          element.$.restAPI,
+          'getIsGroupOwner',
+          () => Promise.resolve(true));
       element.groupId = 1;
       element._loadGroup().then(() => {
         assert.isFalse(element._groupIsInternal);
@@ -114,13 +119,15 @@
       element._groupName = groupName;
       element._groupOwner = true;
 
-      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
-        return Promise.resolve(true);
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getIsGroupOwner',
+          () => Promise.resolve(true));
 
-      sandbox.stub(element.$.restAPI, 'saveGroupName', () => {
-        return Promise.resolve({status: 200});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'saveGroupName',
+          () => Promise.resolve({status: 200}));
 
       const button = element.$.inputUpdateNameBtn;
 
@@ -154,9 +161,10 @@
     test('test for undefined group name', done => {
       groupStub.restore();
 
-      sandbox.stub(element.$.restAPI, 'getGroupConfig', () => {
-        return Promise.resolve({});
-      });
+      sandbox.stub(
+          element.$.restAPI,
+          'getGroupConfig',
+          () => Promise.resolve({}));
 
       assert.isUndefined(element.groupId);
 
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
index 931e2cd..e07f911 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -82,8 +82,12 @@
         display: block;
       }
     </style>
-    <style include="gr-form-styles"></style>
-    <style include="gr-menu-page-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-menu-page-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <section
         id="permission"
         class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 75e715b..508c3a2 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -25,92 +25,104 @@
   ];
 
   /**
+   * @appliesMixin Gerrit.AccessMixin
+   * @appliesMixin Gerrit.FireMixin
+   */
+  /**
    * Fired when the permission has been modified or removed.
    *
    * @event access-modified
    */
-
   /**
    * Fired when a permission that was previously added was removed.
    *
    * @event added-permission-removed
+   * @extends Polymer.Element
    */
+  class GrPermission extends Polymer.mixinBehaviors( [
+    Gerrit.AccessBehavior,
+    /**
+     * Unused in this element, but called by other elements in tests
+     * e.g gr-access-section_test.
+     */
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-permission'; }
 
-  Polymer({
-    is: 'gr-permission',
-
-    properties: {
-      labels: Object,
-      name: String,
-      /** @type {?} */
-      permission: {
-        type: Object,
-        observer: '_sortPermission',
-        notify: true,
-      },
-      groups: Object,
-      section: String,
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      _label: {
-        type: Object,
-        computed: '_computeLabel(permission, labels)',
-      },
-      _groupFilter: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getGroupSuggestions.bind(this);
+    static get properties() {
+      return {
+        labels: Object,
+        name: String,
+        /** @type {?} */
+        permission: {
+          type: Object,
+          observer: '_sortPermission',
+          notify: true,
         },
-      },
-      _rules: Array,
-      _groupsWithRules: Object,
-      _deleted: {
-        type: Boolean,
-        value: false,
-      },
-      _originalExclusiveValue: Boolean,
-    },
+        groups: Object,
+        section: String,
+        editing: {
+          type: Boolean,
+          value: false,
+          observer: '_handleEditingChanged',
+        },
+        _label: {
+          type: Object,
+          computed: '_computeLabel(permission, labels)',
+        },
+        _groupFilter: String,
+        _query: {
+          type: Function,
+          value() {
+            return this._getGroupSuggestions.bind(this);
+          },
+        },
+        _rules: Array,
+        _groupsWithRules: Object,
+        _deleted: {
+          type: Boolean,
+          value: false,
+        },
+        _originalExclusiveValue: Boolean,
+      };
+    }
 
-    behaviors: [
-      Gerrit.AccessBehavior,
-      /**
-       * Unused in this element, but called by other elements in tests
-       * e.g gr-access-section_test.
-       */
-      Gerrit.FireBehavior,
-    ],
+    static get observers() {
+      return [
+        '_handleRulesChanged(_rules.splices)',
+      ];
+    }
 
-    observers: [
-      '_handleRulesChanged(_rules.splices)',
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('access-saved',
+          () => this._handleAccessSaved());
+    }
 
-    listeners: {
-      'access-saved': '_handleAccessSaved',
-    },
-
+    /** @override */
     ready() {
+      super.ready();
       this._setupValues();
-    },
+    }
 
     _setupValues() {
       if (!this.permission) { return; }
       this._originalExclusiveValue = !!this.permission.value.exclusive;
       Polymer.dom.flush();
-    },
+    }
 
     _handleAccessSaved() {
       // Set a new 'original' value to keep track of after the value has been
       // saved.
       this._setupValues();
-    },
+    }
 
     _permissionIsOwnerOrGlobal(permissionId, section) {
       return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
-    },
+    }
 
     _handleEditingChanged(editing, editingOld) {
       // Ignore when editing gets set initially.
@@ -131,20 +143,20 @@
         this.set(['permission', 'value', 'exclusive'],
             this._originalExclusiveValue);
       }
-    },
+    }
 
     _handleAddedRuleRemoved(e) {
       const index = e.model.index;
       this._rules = this._rules.slice(0, index)
           .concat(this._rules.slice(index + 1, this._rules.length));
-    },
+    }
 
     _handleValueChange() {
       this.permission.value.modified = true;
       // Allows overall access page to know a change has been made.
       this.dispatchEvent(
           new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _handleRemovePermission() {
       if (this.permission.value.added) {
@@ -155,16 +167,16 @@
       this.permission.value.deleted = true;
       this.dispatchEvent(
           new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _handleRulesChanged(changeRecord) {
       // Update the groups to exclude in the autocomplete.
       this._groupsWithRules = this._computeGroupsWithRules(this._rules);
-    },
+    }
 
     _sortPermission(permission) {
       this._rules = this.toSortedArray(permission.value.rules);
-    },
+    }
 
     _computeSectionClass(editing, deleted) {
       const classList = [];
@@ -175,12 +187,12 @@
         classList.push('deleted');
       }
       return classList.join(' ');
-    },
+    }
 
     _handleUndoRemove() {
       this._deleted = false;
       delete this.permission.value.deleted;
-    },
+    }
 
     _computeLabel(permission, labels) {
       if (!labels || !permission ||
@@ -196,13 +208,12 @@
         values: this._computeLabelValues(labels[labelName].values),
       };
       return label;
-    },
+    }
 
     _computeLabelValues(values) {
       const valuesArr = [];
-      const keys = Object.keys(values).sort((a, b) => {
-        return parseInt(a, 10) - parseInt(b, 10);
-      });
+      const keys = Object.keys(values)
+          .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
 
       for (const key of keys) {
         let text = values[key];
@@ -212,7 +223,7 @@
         valuesArr.push({value: parseInt(key, 10), text});
       }
       return valuesArr;
-    },
+    }
 
     /**
      * @param {!Array} rules
@@ -225,12 +236,12 @@
         groups[rule.id] = true;
       }
       return groups;
-    },
+    }
 
     _computeGroupName(groups, groupId) {
       return groups && groups[groupId] && groups[groupId].name ?
         groups[groupId].name : groupId;
-    },
+    }
 
     _getGroupSuggestions() {
       return this.$.restAPI.getSuggestedGroups(
@@ -246,11 +257,10 @@
               });
             }
             // Does not return groups in which we already have rules for.
-            return groups.filter(group => {
-              return !this._groupsWithRules[group.value.id];
-            });
+            return groups
+                .filter(group => !this._groupsWithRules[group.value.id]);
           });
-    },
+    }
 
     /**
      * Handles adding a skeleton item to the dom-repeat.
@@ -259,7 +269,8 @@
     _handleAddRuleItem(e) {
       // The group id is encoded, but have to decode in order for the access
       // API to work as expected.
-      const groupId = decodeURIComponent(e.detail.value.id).replace(/\+/g, ' ');
+      const groupId = decodeURIComponent(e.detail.value.id)
+          .replace(/\+/g, ' ');
       // We cannot use "this.set(...)" here, because groupId may contain dots,
       // and dots in property path names are totally unsupported by Polymer.
       // Apparently Polymer picks up this change anyway, otherwise we should
@@ -290,12 +301,14 @@
       this.permission.value.rules[groupId] = value;
       this.dispatchEvent(
           new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _computeHasRange(name) {
       if (!name) { return false; }
 
       return RANGE_NAMES.includes(name.toUpperCase());
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPermission.is, GrPermission);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index 0c70f9c..9928b9e 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-permission</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -385,7 +385,8 @@
 
         assert.isFalse(element._originalExclusiveValue);
         assert.isNotOk(element.permission.value.modified);
-        MockInteractions.tap(element.$$('#exclusiveToggle'));
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('#exclusiveToggle'));
         flushAsynchronousOperations();
         assert.isTrue(element.permission.value.exclusive);
         assert.isTrue(element.permission.value.modified);
@@ -405,21 +406,25 @@
       });
 
       test('Exclusive hidden for owner permission', () => {
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'flex');
+        assert.equal(getComputedStyle(element.shadowRoot
+            .querySelector('#exclusiveToggle')).display,
+        'flex');
         element.set(['permission', 'id'], 'owner');
         flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'none');
+        assert.equal(getComputedStyle(element.shadowRoot
+            .querySelector('#exclusiveToggle')).display,
+        'none');
       });
 
       test('Exclusive hidden for any global permissions', () => {
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'flex');
+        assert.equal(getComputedStyle(element.shadowRoot
+            .querySelector('#exclusiveToggle')).display,
+        'flex');
         element.section = 'GLOBAL_CAPABILITIES';
         flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.$$('#exclusiveToggle')).display,
-            'none');
+        assert.equal(getComputedStyle(element.shadowRoot
+            .querySelector('#exclusiveToggle')).display,
+        'none');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
index 2761526..f6c744b 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.html
@@ -25,7 +25,9 @@
 
 <dom-module id="gr-plugin-config-array-editor">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       .wrapper {
         width: 30em;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
index bb0d501..92a8655 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -17,39 +17,43 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-plugin-config-array-editor',
-
+  /** @extends Polymer.Element */
+  class GrPluginConfigArrayEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-plugin-config-array-editor'; }
     /**
      * Fired when the plugin config option changes.
      *
      * @event plugin-config-option-changed
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      pluginOption: Object,
-      /** @type {Boolean} */
-      disabled: {
-        type: Boolean,
-        computed: '_computeDisabled(pluginOption.*)',
-      },
-      /** @type {?} */
-      _newValue: {
-        type: String,
-        value: '',
-      },
-    },
+        pluginOption: Object,
+        /** @type {boolean} */
+        disabled: {
+          type: Boolean,
+          computed: '_computeDisabled(pluginOption.*)',
+        },
+        /** @type {?} */
+        _newValue: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
     _computeDisabled(record) {
       return !(record && record.base && record.base.info &&
           record.base.info.editable);
-    },
+    }
 
     _handleAddTap(e) {
       e.preventDefault();
       this._handleAdd();
-    },
+    }
 
     _handleInputKeydown(e) {
       // Enter.
@@ -57,20 +61,20 @@
         e.preventDefault();
         this._handleAdd();
       }
-    },
+    }
 
     _handleAdd() {
       if (!this._newValue.length) { return; }
       this._dispatchChanged(
           this.pluginOption.info.values.concat([this._newValue]));
       this._newValue = '';
-    },
+    }
 
     _handleDelete(e) {
       const value = Polymer.dom(e).localTarget.dataset.item;
       this._dispatchChanged(
           this.pluginOption.info.values.filter(str => str !== value));
-    },
+    }
 
     _dispatchChanged(values) {
       const {_key, info} = this.pluginOption;
@@ -81,10 +85,13 @@
       };
       this.dispatchEvent(
           new CustomEvent('plugin-config-option-changed', {detail}));
-    },
+    }
 
     _computeShowInputRow(disabled) {
       return disabled ? 'hide' : '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPluginConfigArrayEditor.is,
+      GrPluginConfigArrayEditor);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
index 39e4ddc..5198ad0 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-config-array-editor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
index 6ef84bf..b056f92 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
@@ -25,8 +25,12 @@
 
 <dom-module id="gr-plugin-list">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <gr-list-view
         filter="[[_filter]]"
         items-per-page="[[_pluginsPerPage]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
index 1dbfdc8..5dd6ec2 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
@@ -17,60 +17,69 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-plugin-list',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.ListViewMixin
+   * @extends Polymer.Element
+   */
+  class GrPluginList extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.ListViewBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-plugin-list'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: {
-        type: Number,
-        value: 0,
-      },
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/plugins',
-      },
-      _plugins: Array,
-      /**
-       * Because  we request one more than the pluginsPerPage, _shownPlugins
-       * maybe one less than _plugins.
-       * */
-      _shownPlugins: {
-        type: Array,
-        computed: 'computeShownItems(_plugins)',
-      },
-      _pluginsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: {
-        type: String,
-        value: '',
-      },
-    },
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+        /**
+         * Offset of currently visible query results.
+         */
+        _offset: {
+          type: Number,
+          value: 0,
+        },
+        _path: {
+          type: String,
+          readOnly: true,
+          value: '/admin/plugins',
+        },
+        _plugins: Array,
+        /**
+         * Because  we request one more than the pluginsPerPage, _shownPlugins
+         * maybe one less than _plugins.
+         * */
+        _shownPlugins: {
+          type: Array,
+          computed: 'computeShownItems(_plugins)',
+        },
+        _pluginsPerPage: {
+          type: Number,
+          value: 25,
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _filter: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.ListViewBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.fire('title-change', {title: 'Plugins'});
-    },
+    }
 
     _paramsChanged(params) {
       this._loading = true;
@@ -79,7 +88,7 @@
 
       return this._getPlugins(this._filter, this._pluginsPerPage,
           this._offset);
-    },
+    }
 
     _getPlugins(filter, pluginsPerPage, offset) {
       const errFn = response => {
@@ -99,14 +108,16 @@
                 });
             this._loading = false;
           });
-    },
+    }
 
     _status(item) {
       return item.disabled === true ? 'Disabled' : 'Enabled';
-    },
+    }
 
     _computePluginUrl(id) {
       return this.getUrl('/', id);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPluginList.is, GrPluginList);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
index 96fff60..a80e575 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -125,9 +125,10 @@
 
     suite('filter', () => {
       test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getPlugins', () => {
-          return Promise.resolve(plugins);
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'getPlugins',
+            () => Promise.resolve(plugins));
         const value = {
           filter: 'test',
           offset: 25,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
index ea12908..fab730d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.html
@@ -33,7 +33,9 @@
 
 <dom-module id="gr-repo-access">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-subpage-styles">
       gr-button,
       #inheritsFrom,
@@ -68,7 +70,9 @@
         display: inline-block;
       }
     </style>
-    <style include="gr-menu-page-styles"></style>
+    <style include="gr-menu-page-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
       <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
         Loading...
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 1e6a431..b350702 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -67,67 +67,79 @@
    */
   Defs.projectAccessInput;
 
+  /**
+   * @appliesMixin Gerrit.AccessMixin
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoAccess extends Polymer.mixinBehaviors( [
+    Gerrit.AccessBehavior,
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-access'; }
 
-  Polymer({
-    is: 'gr-repo-access',
-
-    properties: {
-      repo: {
-        type: String,
-        observer: '_repoChanged',
-      },
-      // The current path
-      path: String,
-
-      _canUpload: {
-        type: Boolean,
-        value: false,
-      },
-      _inheritFromFilter: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getInheritFromSuggestions.bind(this);
+    static get properties() {
+      return {
+        repo: {
+          type: String,
+          observer: '_repoChanged',
         },
-      },
-      _ownerOf: Array,
-      _capabilities: Object,
-      _groups: Object,
-      /** @type {?} */
-      _inheritsFrom: Object,
-      _labels: Object,
-      _local: Object,
-      _editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      _modified: {
-        type: Boolean,
-        value: false,
-      },
-      _sections: Array,
-      _weblinks: Array,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-    },
+        // The current path
+        path: String,
 
-    behaviors: [
-      Gerrit.AccessBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+        _canUpload: {
+          type: Boolean,
+          value: false,
+        },
+        _inheritFromFilter: String,
+        _query: {
+          type: Function,
+          value() {
+            return this._getInheritFromSuggestions.bind(this);
+          },
+        },
+        _ownerOf: Array,
+        _capabilities: Object,
+        _groups: Object,
+        /** @type {?} */
+        _inheritsFrom: Object,
+        _labels: Object,
+        _local: Object,
+        _editing: {
+          type: Boolean,
+          value: false,
+          observer: '_handleEditingChanged',
+        },
+        _modified: {
+          type: Boolean,
+          value: false,
+        },
+        _sections: Array,
+        _weblinks: Array,
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+      };
+    }
 
-    listeners: {
-      'access-modified': '_handleAccessModified',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('access-modified',
+          () =>
+            this._handleAccessModified());
+    }
 
     _handleAccessModified() {
       this._modified = true;
-    },
+    }
 
     /**
      * @param {string} repo
@@ -139,7 +151,7 @@
       if (!repo) { return Promise.resolve(); }
 
       return this._reload(repo);
-    },
+    }
 
     _reload(repo) {
       const promises = [];
@@ -196,7 +208,7 @@
         this._sections = sections;
         this._loading = false;
       });
-    },
+    }
 
     _handleUpdateInheritFrom(e) {
       if (!this._inheritsFrom) {
@@ -205,7 +217,7 @@
       this._inheritsFrom.id = e.detail.value;
       this._inheritsFrom.name = this._inheritFromFilter;
       this._handleAccessModified();
-    },
+    }
 
     _getInheritFromSuggestions() {
       return this.$.restAPI.getRepos(
@@ -222,33 +234,33 @@
             }
             return projects;
           });
-    },
+    }
 
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
-    },
+    }
 
     _handleEdit() {
       this._editing = !this._editing;
-    },
+    }
 
     _editOrCancel(editing) {
       return editing ? 'Cancel' : 'Edit';
-    },
+    }
 
     _computeWebLinkClass(weblinks) {
       return weblinks && weblinks.length ? 'show' : '';
-    },
+    }
 
     _computeShowInherit(inheritsFrom) {
       return inheritsFrom ? 'show' : '';
-    },
+    }
 
     _handleAddedSectionRemoved(e) {
       const index = e.model.index;
       this._sections = this._sections.slice(0, index)
           .concat(this._sections.slice(index + 1, this._sections.length));
-    },
+    }
 
     _handleEditingChanged(editing, editingOld) {
       // Ignore when editing gets set initially.
@@ -267,7 +279,7 @@
           delete this._local[key];
         }
       }
-    },
+    }
 
     /**
      * @param {!Defs.projectAccessInput} addRemoveObj
@@ -297,7 +309,7 @@
         curPos = curPos[item];
       }
       return addRemoveObj;
-    },
+    }
 
     /**
      * Used to recursively remove any objects with a 'deleted' bit.
@@ -314,7 +326,7 @@
           this._recursivelyRemoveDeleted(obj[k]);
         }
       }
-    },
+    }
 
     _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
       for (const k in obj) {
@@ -356,7 +368,7 @@
               path.concat(k));
         }
       }
-    },
+    }
 
     /**
      * Returns an object formatted for saving or submitting access changes for
@@ -379,8 +391,8 @@
 
       const inheritFromChanged =
           // Inherit from changed
-          (originalInheritsFromId
-              && originalInheritsFromId !== inheritsFromId) ||
+          (originalInheritsFromId &&
+              originalInheritsFromId !== inheritsFromId) ||
           // Inherit from added (did not have one initially);
           (!originalInheritsFromId && inheritsFromId);
 
@@ -390,7 +402,7 @@
         addRemoveObj.parent = inheritsFromId;
       }
       return addRemoveObj;
-    },
+    }
 
     _handleCreateSection() {
       let newRef = 'refs/for/*';
@@ -405,7 +417,7 @@
       Polymer.dom.flush();
       Polymer.dom(this.root).querySelector('gr-access-section:last-of-type')
           .editReference();
-    },
+    }
 
     _getObjforSave() {
       const addRemoveObj = this._computeAddAndRemove();
@@ -428,7 +440,7 @@
         obj.parent = addRemoveObj.parent;
       }
       return obj;
-    },
+    }
 
     _handleSave() {
       const obj = this._getObjforSave();
@@ -437,7 +449,7 @@
           .then(() => {
             this._reload(this.repo);
           });
-    },
+    }
 
     _handleSaveForReview() {
       const obj = this._getObjforSave();
@@ -446,15 +458,15 @@
           .then(change => {
             Gerrit.Nav.navigateToChange(change);
           });
-    },
+    }
 
     _computeSaveReviewBtnClass(canUpload) {
       return !canUpload ? 'invisible' : '';
-    },
+    }
 
     _computeSaveBtnClass(ownerOf) {
       return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
-    },
+    }
 
     _computeMainClass(ownerOf, canUpload, editing) {
       const classList = [];
@@ -465,11 +477,13 @@
         classList.push('editing');
       }
       return classList.join(' ');
-    },
+    }
 
     _computeParentHref(repoName) {
       return this.getBaseUrl() +
           `/admin/repos/${this.encodeURL(repoName, true)},access`;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoAccess.is, GrRepoAccess);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
index 1660088..e0e25e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-access</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -282,7 +282,8 @@
           id: 'test-project',
         };
         flushAsynchronousOperations();
-        assert.equal(getComputedStyle(element.$$('#editInheritFromInput'))
+        assert.equal(getComputedStyle(element.shadowRoot
+            .querySelector('#editInheritFromInput'))
             .display, 'none');
 
         MockInteractions.tap(element.$.editBtn);
@@ -300,7 +301,8 @@
           assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
           assert.isTrue(element.$.saveBtn.disabled);
         }
-        assert.notEqual(getComputedStyle(element.$$('#editInheritFromInput'))
+        assert.notEqual(getComputedStyle(element.shadowRoot
+            .querySelector('#editInheritFromInput'))
             .display, 'none');
 
         // Save button should be enabled after access is modified
@@ -365,7 +367,7 @@
           id: 'test-project',
         };
         flushAsynchronousOperations();
-        element.$$('#editInheritFromInput').fire('commit');
+        element.shadowRoot.querySelector('#editInheritFromInput').fire('commit');
         sandbox.spy(element, '_handleAccessModified');
         element.fire('access-modified');
         assert.isTrue(element._handleAccessModified.called);
@@ -577,8 +579,9 @@
           remove: {},
         };
 
-        element.$$('gr-access-section').$$('gr-permission')._handleAddRuleItem(
-            {detail: {value: {id: 'Maintainers'}}});
+        element.$$('gr-access-section').$$('gr-permission')
+            ._handleAddRuleItem(
+                {detail: {value: {id: 'Maintainers'}}});
 
         flushAsynchronousOperations();
         assert.deepEqual(element._computeAddAndRemove(), expectedInput);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
index 026c990..622bfe4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
@@ -17,14 +17,19 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-repo-command',
+  /** @extends Polymer.Element */
+  class GrRepoCommand extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-repo-command'; }
 
-    properties: {
-      title: String,
-      disabled: Boolean,
-      tooltip: String,
-    },
+    static get properties() {
+      return {
+        title: String,
+        disabled: Boolean,
+        tooltip: String,
+      };
+    }
 
     /**
      * Fired when command button is tapped.
@@ -35,6 +40,8 @@
     _onCommandTap() {
       this.dispatchEvent(
           new CustomEvent('command-tap', {bubbles: true, composed: true}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoCommand.is, GrRepoCommand);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
index 49d8765..b8ec7e8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-command</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
index 5089f34..b610460 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.html
@@ -31,9 +31,15 @@
 
 <dom-module id="gr-repo-commands">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-subpage-styles"></style>
-    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-subpage-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <main class="gr-form-styles read-only">
       <h1 id="Title">Repository Commands</h1>
       <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 3b4811e..80b187a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -26,30 +26,38 @@
   const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
   const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
-  Polymer({
-    is: 'gr-repo-commands',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoCommands extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-commands'; }
 
-    properties: {
-      params: Object,
-      repo: String,
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?} */
-      _repoConfig: Object,
-      _canCreate: Boolean,
-    },
+    static get properties() {
+      return {
+        params: Object,
+        repo: String,
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        /** @type {?} */
+        _repoConfig: Object,
+        _canCreate: Boolean,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._loadRepo();
 
       this.fire('title-change', {title: 'Repo Commands'});
-    },
+    }
 
     _loadRepo() {
       if (!this.repo) { return Promise.resolve(); }
@@ -65,15 +73,15 @@
             this._repoConfig = config;
             this._loading = false;
           });
-    },
+    }
 
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
-    },
+    }
 
     _isLoading() {
       return this._loading || this._loading === undefined;
-    },
+    }
 
     _handleRunningGC() {
       return this.$.restAPI.runRepoGC(this.repo).then(response => {
@@ -83,20 +91,20 @@
               {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
         }
       });
-    },
+    }
 
     _createNewChange() {
       this.$.createChangeOverlay.open();
-    },
+    }
 
     _handleCreateChange() {
       this.$.createNewChangeModal.handleCreateChange();
       this._handleCloseCreateChange();
-    },
+    }
 
     _handleCloseCreateChange() {
       this.$.createChangeOverlay.close();
-    },
+    }
 
     _handleEditRepoConfig() {
       return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
@@ -111,6 +119,8 @@
         Gerrit.Nav.navigateToRelativeUrl(Gerrit.Nav.getEditUrlForDiff(
             change, CONFIG_PATH, INITIAL_PATCHSET));
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoCommands.is, GrRepoCommands);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
index 2976923..fc6bd4b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-commands</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -44,9 +44,10 @@
     setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      repoStub = sandbox.stub(element.$.restAPI, 'getProjectConfig', () => {
-        return Promise.resolve({});
-      });
+      repoStub = sandbox.stub(
+          element.$.restAPI,
+          'getProjectConfig',
+          () => Promise.resolve({}));
     });
 
     teardown(() => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
index 8af3a92..f74f705 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
@@ -35,7 +35,9 @@
         display: block;
       }
     </style>
-    <style include="gr-table-styles"></style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <table id="list" class$="genericList [[_computeLoadingClass(_loading)]]">
       <tr class="headerRow">
         <th class="topHeader">Dashboard name</th>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index 71cc571..8e09263 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -17,24 +17,30 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-repo-dashboards',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoDashboards extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-dashboards'; }
 
-    properties: {
-      repo: {
-        type: String,
-        observer: '_repoChanged',
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _dashboards: Array,
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    static get properties() {
+      return {
+        repo: {
+          type: String,
+          observer: '_repoChanged',
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _dashboards: Array,
+      };
+    }
 
     _repoChanged(repo) {
       this._loading = true;
@@ -49,7 +55,7 @@
 
         // Group by ref and sort by id.
         const dashboards = res.concat.apply([], res).sort((a, b) =>
-          a.id < b.id ? -1 : 1);
+          (a.id < b.id ? -1 : 1));
         const dashboardsByRef = {};
         dashboards.forEach(d => {
           if (!dashboardsByRef[d.ref]) {
@@ -59,35 +65,38 @@
         });
 
         const dashboardBuilder = [];
-        Object.keys(dashboardsByRef).sort().forEach(ref => {
-          dashboardBuilder.push({
-            section: ref,
-            dashboards: dashboardsByRef[ref],
-          });
-        });
+        Object.keys(dashboardsByRef).sort()
+            .forEach(ref => {
+              dashboardBuilder.push({
+                section: ref,
+                dashboards: dashboardsByRef[ref],
+              });
+            });
 
         this._dashboards = dashboardBuilder;
         this._loading = false;
         Polymer.dom.flush();
       });
-    },
+    }
 
     _getUrl(project, id) {
       if (!project || !id) { return ''; }
 
       return Gerrit.Nav.getUrlForRepoDashboard(project, id);
-    },
+    }
 
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
-    },
+    }
 
     _computeInheritedFrom(project, definingProject) {
       return project === definingProject ? '' : definingProject;
-    },
+    }
 
     _computeIsDefault(isDefault) {
       return isDefault ? '✓' : '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoDashboards.is, GrRepoDashboards);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index 4f76983..06496de 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-dashboards</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
index 2f244f8..467cef0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.html
@@ -35,8 +35,12 @@
 
 <dom-module id="gr-repo-detail-list">
   <template>
-    <style include="gr-form-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       .tags td.name {
         min-width: 25em;
@@ -81,7 +85,9 @@
         display: none;
       }
     </style>
-    <style include="gr-table-styles"></style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <gr-list-view
         create-new="[[_loggedIn]]"
         filter="[[_filter]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
index 9052322..ccfdfc6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
@@ -24,75 +24,83 @@
 
   const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
-  Polymer({
-    is: 'gr-repo-detail-list',
+  /**
+   * @appliesMixin Gerrit.ListViewMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoDetailList extends Polymer.mixinBehaviors( [
+    Gerrit.ListViewBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-detail-list'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /**
-       * The kind of detail we are displaying, possibilities are determined by
-       * the const DETAIL_TYPES.
-       */
-      detailType: String,
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+        /**
+         * The kind of detail we are displaying, possibilities are determined by
+         * the const DETAIL_TYPES.
+         */
+        detailType: String,
 
-      _editing: {
-        type: Boolean,
-        value: false,
-      },
-      _isOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _repo: Object,
-      _items: Array,
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       */
-      _shownItems: {
-        type: Array,
-        computed: 'computeShownItems(_items)',
-      },
-      _itemsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-      _refName: String,
-      _hasNewItemName: Boolean,
-      _isEditing: Boolean,
-      _revisedRef: String,
-    },
-
-    behaviors: [
-      Gerrit.ListViewBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+        _editing: {
+          type: Boolean,
+          value: false,
+        },
+        _isOwner: {
+          type: Boolean,
+          value: false,
+        },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        /**
+         * Offset of currently visible query results.
+         */
+        _offset: Number,
+        _repo: Object,
+        _items: Array,
+        /**
+         * Because  we request one more than the projectsPerPage, _shownProjects
+         * maybe one less than _projects.
+         */
+        _shownItems: {
+          type: Array,
+          computed: 'computeShownItems(_items)',
+        },
+        _itemsPerPage: {
+          type: Number,
+          value: 25,
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _filter: String,
+        _refName: String,
+        _hasNewItemName: Boolean,
+        _isEditing: Boolean,
+        _revisedRef: String,
+      };
+    }
 
     _determineIfOwner(repo) {
       return this.$.restAPI.getRepoAccess(repo)
           .then(access =>
             this._isOwner = access && !!access[repo].is_owner);
-    },
+    }
 
     _paramsChanged(params) {
       if (!params || !params.repo) { return; }
@@ -113,7 +121,7 @@
 
       return this._getItems(this._filter, this._repo,
           this._itemsPerPage, this._offset, this.detailType);
-    },
+    }
 
     _getItems(filter, repo, itemsPerPage, offset, detailType) {
       this._loading = true;
@@ -137,24 +145,24 @@
           this._loading = false;
         });
       }
-    },
+    }
 
     _getPath(repo) {
       return `/admin/repos/${this.encodeURL(repo, false)},` +
           `${this.detailType}`;
-    },
+    }
 
     _computeWeblink(repo) {
       if (!repo.web_links) { return ''; }
       const webLinks = repo.web_links;
       return webLinks.length ? webLinks : null;
-    },
+    }
 
     _computeMessage(message) {
       if (!message) { return; }
       // Strip PGP info.
       return message.split(PGP_START)[0];
-    },
+    }
 
     _stripRefs(item, detailType) {
       if (detailType === DETAIL_TYPES.BRANCHES) {
@@ -162,33 +170,33 @@
       } else if (detailType === DETAIL_TYPES.TAGS) {
         return item.replace('refs/tags/', '');
       }
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _computeEditingClass(isEditing) {
       return isEditing ? 'editing' : '';
-    },
+    }
 
     _computeCanEditClass(ref, detailType, isOwner) {
       return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
         'canEdit' : '';
-    },
+    }
 
     _handleEditRevision(e) {
       this._revisedRef = e.model.get('item.revision');
       this._isEditing = true;
-    },
+    }
 
     _handleCancelRevision() {
       this._isEditing = false;
-    },
+    }
 
     _handleSaveRevision(e) {
       this._setRepoHead(this._repo, this._revisedRef, e);
-    },
+    }
 
     _setRepoHead(repo, ref, e) {
       return this.$.restAPI.setRepoHead(repo, ref).then(res => {
@@ -202,7 +210,7 @@
               this._offset, this.detailType);
         }
       });
-    },
+    }
 
     _computeItemName(detailType) {
       if (detailType === DETAIL_TYPES.BRANCHES) {
@@ -210,7 +218,7 @@
       } else if (detailType === DETAIL_TYPES.TAGS) {
         return 'Tag';
       }
-    },
+    }
 
     _handleDeleteItemConfirm() {
       this.$.overlay.close();
@@ -233,18 +241,18 @@
               }
             });
       }
-    },
+    }
 
     _handleConfirmDialogCancel() {
       this.$.overlay.close();
-    },
+    }
 
     _handleDeleteItem(e) {
       const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
       if (!name) { return; }
       this._refName = name;
       this.$.overlay.open();
-    },
+    }
 
     _computeHideDeleteClass(owner, canDelete) {
       if (canDelete || owner) {
@@ -252,20 +260,20 @@
       }
 
       return '';
-    },
+    }
 
     _handleCreateItem() {
       this.$.createNewModal.handleCreateItem();
       this._handleCloseCreate();
-    },
+    }
 
     _handleCloseCreate() {
       this.$.createOverlay.close();
-    },
+    }
 
     _handleCreateClicked() {
       this.$.createOverlay.open();
-    },
+    }
 
     _hideIfBranch(type) {
       if (type === DETAIL_TYPES.BRANCHES) {
@@ -273,10 +281,12 @@
       }
 
       return '';
-    },
+    }
 
     _computeHideTagger(tagger) {
       return tagger ? '' : 'hide';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoDetailList.is, GrRepoDetailList);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
index 44d9b27..0a99e60 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-detail-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -297,9 +297,10 @@
 
     suite('filter', () => {
       test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getRepoBranches', () => {
-          return Promise.resolve(branches);
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'getRepoBranches',
+            () => Promise.resolve(branches));
         const params = {
           detail: 'branches',
           repo: 'test',
@@ -480,9 +481,10 @@
 
     suite('filter', () => {
       test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getRepoTags', () => {
-          return Promise.resolve(tags);
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'getRepoTags',
+            () => Promise.resolve(tags));
         const params = {
           repo: 'test',
           detail: 'tags',
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
index 5e82c1e..08fd45c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
@@ -27,8 +27,12 @@
 
 <dom-module id="gr-repo-list">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style>
       .genericList tr td:last-of-type {
         text-align: left;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index 0eaa496..c509717 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -17,67 +17,75 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-repo-list',
+  /**
+   * @appliesMixin Gerrit.ListViewMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoList extends Polymer.mixinBehaviors( [
+    Gerrit.ListViewBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-list'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
 
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/admin/repos',
-      },
-      _hasNewRepoName: Boolean,
-      _createNewCapability: {
-        type: Boolean,
-        value: false,
-      },
-      _repos: Array,
+        /**
+         * Offset of currently visible query results.
+         */
+        _offset: Number,
+        _path: {
+          type: String,
+          readOnly: true,
+          value: '/admin/repos',
+        },
+        _hasNewRepoName: Boolean,
+        _createNewCapability: {
+          type: Boolean,
+          value: false,
+        },
+        _repos: Array,
 
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       * */
-      _shownRepos: {
-        type: Array,
-        computed: 'computeShownItems(_repos)',
-      },
+        /**
+         * Because  we request one more than the projectsPerPage, _shownProjects
+         * maybe one less than _projects.
+         * */
+        _shownRepos: {
+          type: Array,
+          computed: 'computeShownItems(_repos)',
+        },
 
-      _reposPerPage: {
-        type: Number,
-        value: 25,
-      },
+        _reposPerPage: {
+          type: Number,
+          value: 25,
+        },
 
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: {
-        type: String,
-        value: '',
-      },
-    },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _filter: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._getCreateRepoCapability();
       this.fire('title-change', {title: 'Repos'});
       this._maybeOpenCreateOverlay(this.params);
-    },
+    }
 
     _paramsChanged(params) {
       this._loading = true;
@@ -86,7 +94,7 @@
 
       return this._getRepos(this._filter, this._reposPerPage,
           this._offset);
-    },
+    }
 
     /**
      * Opens the create overlay if the route has a hash 'create'
@@ -97,15 +105,15 @@
       if (params && params.openCreateModal) {
         this.$.createOverlay.open();
       }
-    },
+    }
 
     _computeRepoUrl(name) {
       return this.getUrl(this._path + '/', name);
-    },
+    }
 
     _computeChangesLink(name) {
       return Gerrit.Nav.getUrlForProjectChanges(name);
-    },
+    }
 
     _getCreateRepoCapability() {
       return this.$.restAPI.getAccount().then(account => {
@@ -117,7 +125,7 @@
               }
             });
       });
-    },
+    }
 
     _getRepos(filter, reposPerPage, offset) {
       this._repos = [];
@@ -128,36 +136,38 @@
             this._repos = repos;
             this._loading = false;
           });
-    },
+    }
 
     _refreshReposList() {
       this.$.restAPI.invalidateReposCache();
       return this._getRepos(this._filter, this._reposPerPage,
           this._offset);
-    },
+    }
 
     _handleCreateRepo() {
       this.$.createNewModal.handleCreateRepo().then(() => {
         this._refreshReposList();
       });
-    },
+    }
 
     _handleCloseCreate() {
       this.$.createOverlay.close();
-    },
+    }
 
     _handleCreateClicked() {
       this.$.createOverlay.open();
-    },
+    }
 
     _readOnly(item) {
       return item.state === 'READ_ONLY' ? 'Y' : '';
-    },
+    }
 
     _computeWeblink(repo) {
       if (!repo.web_links) { return ''; }
       const webLinks = repo.web_links;
       return webLinks.length ? webLinks : null;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoList.is, GrRepoList);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
index c77592c..aed5c77 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -127,9 +127,7 @@
       });
 
       test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getRepos', () => {
-          return Promise.resolve(repos);
-        });
+        sandbox.stub(element.$.restAPI, 'getRepos', () => Promise.resolve(repos));
         const value = {
           filter: 'test',
           offset: 25,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
index d2093e4..ef5b755 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.html
@@ -30,8 +30,12 @@
 
 <dom-module id="gr-repo-plugin-config">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-subpage-styles">
       .inherited {
         color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
index bfa8832..7368eb8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -17,66 +17,72 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-repo-plugin-config',
-
+  /**
+   * @appliesMixin Gerrit.RepoPluginConfigMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoPluginConfig extends Polymer.mixinBehaviors( [
+    Gerrit.RepoPluginConfig,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-plugin-config'; }
     /**
      * Fired when the plugin config changes.
      *
      * @event plugin-config-changed
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      pluginData: Object,
-      /** @type {Array} */
-      _pluginConfigOptions: {
-        type: Array,
-        computed: '_computePluginConfigOptions(pluginData.*)',
-      },
-    },
-
-    behaviors: [
-      Gerrit.RepoPluginConfig,
-    ],
+        pluginData: Object,
+        /** @type {Array} */
+        _pluginConfigOptions: {
+          type: Array,
+          computed: '_computePluginConfigOptions(pluginData.*)',
+        },
+      };
+    }
 
     _computePluginConfigOptions(dataRecord) {
       if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
         return [];
       }
       const {config} = dataRecord.base;
-      return Object.keys(config).map(_key => ({_key, info: config[_key]}));
-    },
+      return Object.keys(config)
+          .map(_key => { return {_key, info: config[_key]}; });
+    }
 
     _isArray(type) {
       return type === this.ENTRY_TYPES.ARRAY;
-    },
+    }
 
     _isBoolean(type) {
       return type === this.ENTRY_TYPES.BOOLEAN;
-    },
+    }
 
     _isList(type) {
       return type === this.ENTRY_TYPES.LIST;
-    },
+    }
 
     _isString(type) {
       // Treat numbers like strings for simplicity.
       return type === this.ENTRY_TYPES.STRING ||
           type === this.ENTRY_TYPES.INT ||
           type === this.ENTRY_TYPES.LONG;
-    },
+    }
 
     _computeDisabled(editable) {
       return editable === 'false';
-    },
+    }
 
     /**
      * @param {string} value - fallback to 'false' if undefined
      */
     _computeChecked(value = 'false') {
       return JSON.parse(value);
-    },
+    }
 
     _handleStringChange(e) {
       const el = Polymer.dom(e).localTarget;
@@ -84,7 +90,7 @@
       const configChangeInfo =
           this._buildConfigChangeInfo(el.value, _key);
       this._handleChange(configChangeInfo);
-    },
+    }
 
     _handleListChange(e) {
       const el = Polymer.dom(e).localTarget;
@@ -92,7 +98,7 @@
       const configChangeInfo =
           this._buildConfigChangeInfo(el.value, _key);
       this._handleChange(configChangeInfo);
-    },
+    }
 
     _handleBooleanChange(e) {
       const el = Polymer.dom(e).localTarget;
@@ -100,7 +106,7 @@
       const configChangeInfo =
           this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
       this._handleChange(configChangeInfo);
-    },
+    }
 
     _buildConfigChangeInfo(value, _key) {
       const info = this.pluginData.config[_key];
@@ -110,11 +116,11 @@
         info,
         notifyPath: `${_key}.value`,
       };
-    },
+    }
 
     _handleArrayChange({detail}) {
       this._handleChange(detail);
-    },
+    }
 
     _handleChange({_key, info, notifyPath}) {
       const {name, config} = this.pluginData;
@@ -128,6 +134,8 @@
 
       this.dispatchEvent(new CustomEvent(
           this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoPluginConfig.is, GrRepoPluginConfig);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
index 0a6846f..ff0e522 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-plugin-config</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
index 5de77b9..5e37261 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -32,7 +32,9 @@
 
 <dom-module id="gr-repo">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-subpage-styles">
       h2.edited:after {
         color: var(--deemphasized-text-color);
@@ -55,9 +57,13 @@
         display: block;
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <main class="gr-form-styles read-only">
-      <style include="shared-styles"></style>
+      <style include="shared-styles">
+        /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+      </style>
       <div class="info">
         <h1 id="Title" class$="name">
           [[repo]]
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index 153a137..f6328de 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -51,76 +51,86 @@
     },
   };
 
-  Polymer({
-    is: 'gr-repo',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrRepo extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo'; }
 
-    properties: {
-      params: Object,
-      repo: String,
+    static get properties() {
+      return {
+        params: Object,
+        repo: String,
 
-      _configChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      /** @type {?} */
-      _repoConfig: Object,
-      /** @type {?} */
-      _pluginData: {
-        type: Array,
-        computed: '_computePluginData(_repoConfig.plugin_config.*)',
-      },
-      _readOnly: {
-        type: Boolean,
-        value: true,
-      },
-      _states: {
-        type: Array,
-        value() {
-          return Object.values(STATES);
+        _configChanged: {
+          type: Boolean,
+          value: false,
         },
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(SUBMIT_TYPES);
+        _loading: {
+          type: Boolean,
+          value: true,
         },
-      },
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(_schemesObj)',
-        observer: '_schemesChanged',
-      },
-      _selectedCommand: {
-        type: String,
-        value: 'Clone',
-      },
-      _selectedScheme: String,
-      _schemesObj: Object,
-    },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+          observer: '_loggedInChanged',
+        },
+        /** @type {?} */
+        _repoConfig: Object,
+        /** @type {?} */
+        _pluginData: {
+          type: Array,
+          computed: '_computePluginData(_repoConfig.plugin_config.*)',
+        },
+        _readOnly: {
+          type: Boolean,
+          value: true,
+        },
+        _states: {
+          type: Array,
+          value() {
+            return Object.values(STATES);
+          },
+        },
+        _submitTypes: {
+          type: Array,
+          value() {
+            return Object.values(SUBMIT_TYPES);
+          },
+        },
+        _schemes: {
+          type: Array,
+          value() { return []; },
+          computed: '_computeSchemes(_schemesObj)',
+          observer: '_schemesChanged',
+        },
+        _selectedCommand: {
+          type: String,
+          value: 'Clone',
+        },
+        _selectedScheme: String,
+        _schemesObj: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    static get observers() {
+      return [
+        '_handleConfigChanged(_repoConfig.*)',
+      ];
+    }
 
-    observers: [
-      '_handleConfigChanged(_repoConfig.*)',
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._loadRepo();
 
       this.fire('title-change', {title: this.repo});
-    },
+    }
 
     _computePluginData(configRecord) {
       if (!configRecord ||
@@ -128,8 +138,8 @@
 
       const pluginConfig = configRecord.base;
       return Object.keys(pluginConfig)
-          .map(name => ({name, config: pluginConfig[name]}));
-    },
+          .map(name => { return {name, config: pluginConfig[name]}; });
+    }
 
     _loadRepo() {
       if (!this.repo) { return Promise.resolve(); }
@@ -179,15 +189,15 @@
       }));
 
       return Promise.all(promises);
-    },
+    }
 
     _computeLoadingClass(loading) {
       return loading ? 'loading' : '';
-    },
+    }
 
     _computeHideClass(arr) {
       return !arr || !arr.length ? 'hide' : '';
-    },
+    }
 
     _loggedInChanged(_loggedIn) {
       if (!_loggedIn) { return; }
@@ -197,7 +207,7 @@
           this._selectedScheme = prefs.download_scheme.toLowerCase();
         }
       });
-    },
+    }
 
     _formatBooleanSelect(item) {
       if (!item) { return; }
@@ -218,7 +228,7 @@
           value: 'FALSE',
         },
       ];
-    },
+    }
 
     _formatSubmitTypeSelect(projectConfig) {
       if (!projectConfig) { return; }
@@ -248,15 +258,15 @@
         },
         ...allValues,
       ];
-    },
+    }
 
     _isLoading() {
       return this._loading || this._loading === undefined;
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _formatRepoConfigForSave(repoConfig) {
       const configInputObj = {};
@@ -278,38 +288,38 @@
         }
       }
       return configInputObj;
-    },
+    }
 
     _handleSaveRepoConfig() {
       return this.$.restAPI.saveRepoConfig(this.repo,
           this._formatRepoConfigForSave(this._repoConfig)).then(() => {
         this._configChanged = false;
       });
-    },
+    }
 
     _handleConfigChanged() {
       if (this._isLoading()) { return; }
       this._configChanged = true;
-    },
+    }
 
     _computeButtonDisabled(readOnly, configChanged) {
       return readOnly || !configChanged;
-    },
+    }
 
     _computeHeaderClass(configChanged) {
       return configChanged ? 'edited' : '';
-    },
+    }
 
     _computeSchemes(schemesObj) {
       return Object.keys(schemesObj);
-    },
+    }
 
     _schemesChanged(schemes) {
       if (schemes.length === 0) { return; }
       if (!schemes.includes(this._selectedScheme)) {
         this._selectedScheme = schemes.sort()[0];
       }
-    },
+    }
 
     _computeCommands(repo, schemesObj, _selectedScheme) {
       if (!schemesObj || !repo || !_selectedScheme) {
@@ -331,19 +341,21 @@
         });
       }
       return commands;
-    },
+    }
 
     _computeRepositoriesClass(config) {
       return config ? 'showConfig': '';
-    },
+    }
 
     _computeChangesUrl(name) {
       return Gerrit.Nav.getUrlForProjectChanges(name);
-    },
+    }
 
     _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
       this._repoConfig.plugin_config[name] = config;
       this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepo.is, GrRepo);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
index 4e81565..7b79a7e3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -120,9 +120,10 @@
         },
       });
       element = fixture('basic');
-      repoStub = sandbox.stub(element.$.restAPI, 'getProjectConfig', () => {
-        return Promise.resolve(repoConf);
-      });
+      repoStub = sandbox.stub(
+          element.$.restAPI,
+          'getProjectConfig',
+          () => Promise.resolve(repoConf));
     });
 
     teardown(() => {
@@ -187,12 +188,11 @@
 
     test('form defaults to read only when logged in and not admin', done => {
       element.repo = REPO;
-      sandbox.stub(element, '_getLoggedIn', () => {
-        return Promise.resolve(true);
-      });
-      sandbox.stub(element.$.restAPI, 'getRepoAccess', () => {
-        return Promise.resolve({'test-repo': {}});
-      });
+      sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
+      sandbox.stub(
+          element.$.restAPI,
+          'getRepoAccess',
+          () => Promise.resolve({'test-repo': {}}));
       element._loadRepo().then(() => {
         assert.isTrue(element._readOnly);
         done();
@@ -280,12 +280,11 @@
     suite('admin', () => {
       setup(() => {
         element.repo = REPO;
-        sandbox.stub(element, '_getLoggedIn', () => {
-          return Promise.resolve(true);
-        });
-        sandbox.stub(element.$.restAPI, 'getRepoAccess', () => {
-          return Promise.resolve({'test-repo': {is_owner: true}});
-        });
+        sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
+        sandbox.stub(
+            element.$.restAPI,
+            'getRepoAccess',
+            () => Promise.resolve({'test-repo': {is_owner: true}}));
       });
 
       test('all form elements are enabled', done => {
@@ -308,13 +307,17 @@
         });
       });
 
-      test('inherited submit type value is calculated correctly', () => {
-        return element._loadRepo().then(() => {
-          const sel = element.$.submitTypeSelect;
-          assert.equal(sel.bindValue, 'INHERIT');
-          assert.equal(
-              sel.nativeSelect.options[0].text, 'Inherit (Merge if necessary)');
-        });
+      test('inherited submit type value is calculated correctly', done => {
+        element
+            ._loadRepo().then(() => {
+              const sel = element.$.submitTypeSelect;
+              assert.equal(sel.bindValue, 'INHERIT');
+              assert.equal(
+                  sel.nativeSelect.options[0].text,
+                  'Inherit (Merge if necessary)'
+              );
+              done();
+            });
       });
 
       test('fields update and save correctly', () => {
@@ -338,9 +341,7 @@
         };
 
         const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
-            , () => {
-              return Promise.resolve({});
-            });
+            , () => Promise.resolve({}));
 
         const button = Polymer.dom(element.root).querySelector('gr-button');
 
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 8d94a90..ac98d33 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -64,62 +64,80 @@
     },
   ];
 
-  Polymer({
-    is: 'gr-rule-editor',
+  /**
+   * @appliesMixin Gerrit.AccessMixin
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrRuleEditor extends Polymer.mixinBehaviors( [
+    Gerrit.AccessBehavior,
+    Gerrit.BaseUrlBehavior,
+    /**
+     * Unused in this element, but called by other elements in tests
+     * e.g gr-permission_test.
+     */
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-rule-editor'; }
 
-    properties: {
-      hasRange: Boolean,
-      /** @type {?} */
-      label: Object,
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_handleEditingChanged',
-      },
-      groupId: String,
-      groupName: String,
-      permission: String,
-      /** @type {?} */
-      rule: {
-        type: Object,
-        notify: true,
-      },
-      section: String,
+    static get properties() {
+      return {
+        hasRange: Boolean,
+        /** @type {?} */
+        label: Object,
+        editing: {
+          type: Boolean,
+          value: false,
+          observer: '_handleEditingChanged',
+        },
+        groupId: String,
+        groupName: String,
+        permission: String,
+        /** @type {?} */
+        rule: {
+          type: Object,
+          notify: true,
+        },
+        section: String,
 
-      _deleted: {
-        type: Boolean,
-        value: false,
-      },
-      _originalRuleValues: Object,
-    },
+        _deleted: {
+          type: Boolean,
+          value: false,
+        },
+        _originalRuleValues: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.AccessBehavior,
-      Gerrit.BaseUrlBehavior,
-      /**
-       * Unused in this element, but called by other elements in tests
-       * e.g gr-permission_test.
-       */
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+    static get observers() {
+      return [
+        '_handleValueChange(rule.value.*)',
+      ];
+    }
 
-    observers: [
-      '_handleValueChange(rule.value.*)',
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('access-saved',
+          () => this._handleAccessSaved());
+    }
 
-    listeners: {
-      'access-saved': '_handleAccessSaved',
-    },
-
+    /** @override */
     ready() {
+      super.ready();
       // Called on ready rather than the observer because when new rules are
       // added, the observer is triggered prior to being ready.
       if (!this.rule) { return; } // Check needed for test purposes.
       this._setupValues(this.rule);
-    },
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       if (!this.rule) { return; } // Check needed for test purposes.
       if (!this._originalRuleValues) {
         // Observer _handleValueChange is called after the ready()
@@ -127,13 +145,13 @@
         // avoid set .modified flag to true
         this._setOriginalRuleValues(this.rule.value);
       }
-    },
+    }
 
     _setupValues(rule) {
       if (!rule.value) {
         this._setDefaultRuleValues();
       }
-    },
+    }
 
     _computeForce(permission, action) {
       if (this.permissionValues.push.id === permission &&
@@ -142,21 +160,21 @@
       }
 
       return this.permissionValues.editTopicName.id === permission;
-    },
+    }
 
     _computeForceClass(permission, action) {
       return this._computeForce(permission, action) ? 'force' : '';
-    },
+    }
 
     _computeGroupPath(group) {
       return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
-    },
+    }
 
     _handleAccessSaved() {
       // Set a new 'original' value to keep track of after the value has been
       // saved.
       this._setOriginalRuleValues(this.rule.value);
-    },
+    }
 
     _handleEditingChanged(editing, editingOld) {
       // Ignore when editing gets set initially.
@@ -165,7 +183,7 @@
       if (!editing) {
         this._handleUndoChange();
       }
-    },
+    }
 
     _computeSectionClass(editing, deleted) {
       const classList = [];
@@ -176,7 +194,7 @@
         classList.push('deleted');
       }
       return classList.join(' ');
-    },
+    }
 
     _computeForceOptions(permission, action) {
       if (permission === this.permissionValues.push.id) {
@@ -191,7 +209,7 @@
         return FORCE_EDIT_OPTIONS;
       }
       return [];
-    },
+    }
 
     _getDefaultRuleValues(permission, label) {
       const ruleAction = Action.ALLOW;
@@ -208,19 +226,19 @@
       }
       value.action = DROPDOWN_OPTIONS[0];
       return value;
-    },
+    }
 
     _setDefaultRuleValues() {
       this.set('rule.value', this._getDefaultRuleValues(this.permission,
           this.label));
-    },
+    }
 
     _computeOptions(permission) {
       if (permission === 'priority') {
         return PRIORITY_OPTIONS;
       }
       return DROPDOWN_OPTIONS;
-    },
+    }
 
     _handleRemoveRule() {
       if (this.rule.value.added) {
@@ -231,12 +249,12 @@
       this.rule.value.deleted = true;
       this.dispatchEvent(
           new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _handleUndoRemove() {
       this._deleted = false;
       delete this.rule.value.deleted;
-    },
+    }
 
     _handleUndoChange() {
       // gr-permission will take care of removing rules that were added but
@@ -246,7 +264,7 @@
       this._deleted = false;
       delete this.rule.value.deleted;
       delete this.rule.value.modified;
-    },
+    }
 
     _handleValueChange() {
       if (!this._originalRuleValues) { return; }
@@ -254,10 +272,12 @@
       // Allows overall access page to know a change has been made.
       this.dispatchEvent(
           new CustomEvent('access-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _setOriginalRuleValues(value) {
       this._originalRuleValues = Object.assign({}, value);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRuleEditor.is, GrRuleEditor);
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 6d533af..dbf5bb0 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rule-editor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index a6c86bb..f1338fb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -124,7 +124,9 @@
         }
       }
     </style>
-    <style include="gr-change-list-styles"></style>
+    <style include="gr-change-list-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <td class="cell leftPadding"></td>
     <td class="cell star" hidden$="[[!showStar]]" hidden>
       <gr-change-star change="{{change}}"></gr-change-star>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 8eb39891..013b44b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -24,57 +24,69 @@
     LARGE: 1000,
   };
 
-  Polymer({
-    is: 'gr-change-list-item',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.ChangeTableMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeListItem extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.ChangeTableBehavior,
+    Gerrit.PathListBehavior,
+    Gerrit.RESTClientBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-list-item'; }
 
-    properties: {
-      visibleChangeTableColumns: Array,
-      labelNames: {
-        type: Array,
-      },
+    static get properties() {
+      return {
+        visibleChangeTableColumns: Array,
+        labelNames: {
+          type: Array,
+        },
 
-      /** @type {?} */
-      change: Object,
-      changeURL: {
-        type: String,
-        computed: '_computeChangeURL(change)',
-      },
-      statuses: {
-        type: Array,
-        computed: 'changeStatuses(change)',
-      },
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showNumber: Boolean,
-      _changeSize: {
-        type: String,
-        computed: '_computeChangeSize(change)',
-      },
-      _dynamicCellEndpoints: {
-        type: Array,
-      },
-    },
+        /** @type {?} */
+        change: Object,
+        changeURL: {
+          type: String,
+          computed: '_computeChangeURL(change)',
+        },
+        statuses: {
+          type: Array,
+          computed: 'changeStatuses(change)',
+        },
+        showStar: {
+          type: Boolean,
+          value: false,
+        },
+        showNumber: Boolean,
+        _changeSize: {
+          type: String,
+          computed: '_computeChangeSize(change)',
+        },
+        _dynamicCellEndpoints: {
+          type: Array,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.ChangeTableBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.RESTClientBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       Gerrit.awaitPluginsLoaded().then(() => {
         this._dynamicCellEndpoints = Gerrit._endpoints.getDynamicEndpoints(
             'change-list-item-cell');
       });
-    },
+    }
 
     _computeChangeURL(change) {
       return Gerrit.Nav.getUrlForChange(change);
-    },
+    }
 
     _computeLabelTitle(change, labelName) {
       const label = change.labels[labelName];
@@ -85,7 +97,7 @@
         return labelName + '\nby ' + significantLabel.name;
       }
       return labelName;
-    },
+    }
 
     _computeLabelClass(change, labelName) {
       const label = change.labels[labelName];
@@ -111,8 +123,9 @@
       } else {
         classes['u-gray-background'] = true;
       }
-      return Object.keys(classes).sort().join(' ');
-    },
+      return Object.keys(classes).sort()
+          .join(' ');
+    }
 
     _computeLabelValue(change, labelName) {
       const label = change.labels[labelName];
@@ -130,22 +143,22 @@
         return label.value;
       }
       return '';
-    },
+    }
 
     _computeRepoUrl(change) {
       return Gerrit.Nav.getUrlForProjectChanges(change.project, true,
           change.internalHost);
-    },
+    }
 
     _computeRepoBranchURL(change) {
       return Gerrit.Nav.getUrlForBranch(change.branch, change.project, null,
           change.internalHost);
-    },
+    }
 
     _computeTopicURL(change) {
       if (!change.topic) { return ''; }
       return Gerrit.Nav.getUrlForTopic(change.topic, change.internalHost);
-    },
+    }
 
     /**
      * Computes the display string for the project column. If there is a host
@@ -162,7 +175,7 @@
       if (change.internalHost) { str += change.internalHost + '/'; }
       str += truncate ? this.truncatePath(change.project, 2) : change.project;
       return str;
-    },
+    }
 
     _computeSizeTooltip(change) {
       if (change.insertions + change.deletions === 0 ||
@@ -171,7 +184,7 @@
       } else {
         return `+${change.insertions}, -${change.deletions}`;
       }
-    },
+    }
 
     /**
      * TShirt sizing is based on the following paper:
@@ -193,7 +206,7 @@
       } else {
         return 'XL';
       }
-    },
+    }
 
     toggleReviewed() {
       const newVal = !this.change.reviewed;
@@ -203,6 +216,8 @@
         composed: true,
         detail: {change: this.change, reviewed: newVal},
       }));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeListItem.is, GrChangeListItem);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index afae619..77c4791 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-item</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index be4c497..e3a3b31 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -29,112 +29,125 @@
 
   const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
-  Polymer({
-    is: 'gr-change-list-view',
-
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeListView extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-list-view'; }
     /**
      * Fired when the title of the page should change.
      *
      * @event title-change
      */
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
 
-      /**
-       * True when user is logged in.
-       */
-      _loggedIn: {
-        type: Boolean,
-        computed: '_computeLoggedIn(account)',
-      },
+        /**
+         * True when user is logged in.
+         */
+        _loggedIn: {
+          type: Boolean,
+          computed: '_computeLoggedIn(account)',
+        },
 
-      account: {
-        type: Object,
-        value: null,
-      },
+        account: {
+          type: Object,
+          value: null,
+        },
 
-      /**
-       * State persisted across restamps of the element.
-       *
-       * Need sub-property declaration since it is used in template before
-       * assignment.
-       *
-       * @type {{ selectedChangeIndex: (number|undefined) }}
-       *
-       */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
+        /**
+         * State persisted across restamps of the element.
+         *
+         * Need sub-property declaration since it is used in template before
+         * assignment.
+         *
+         * @type {{ selectedChangeIndex: (number|undefined) }}
+         *
+         */
+        viewState: {
+          type: Object,
+          notify: true,
+          value() { return {}; },
+        },
 
-      preferences: Object,
+        preferences: Object,
 
-      _changesPerPage: Number,
+        _changesPerPage: Number,
 
-      /**
-       * Currently active query.
-       */
-      _query: {
-        type: String,
-        value: '',
-      },
+        /**
+         * Currently active query.
+         */
+        _query: {
+          type: String,
+          value: '',
+        },
 
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
+        /**
+         * Offset of currently visible query results.
+         */
+        _offset: Number,
 
-      /**
-       * Change objects loaded from the server.
-       */
-      _changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
+        /**
+         * Change objects loaded from the server.
+         */
+        _changes: {
+          type: Array,
+          observer: '_changesChanged',
+        },
 
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
+        /**
+         * For showing a "loading..." string during ajax requests.
+         */
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
 
-      /** @type {?String} */
-      _userId: {
-        type: String,
-        value: null,
-      },
+        /** @type {?string} */
+        _userId: {
+          type: String,
+          value: null,
+        },
 
-      /** @type {?String} */
-      _repo: {
-        type: String,
-        value: null,
-      },
-    },
+        /** @type {?string} */
+        _repo: {
+          type: String,
+          value: null,
+        },
+      };
+    }
 
-    listeners: {
-      'next-page': '_handleNextPage',
-      'previous-page': '_handlePreviousPage',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('next-page',
+          () => this._handleNextPage());
+      this.addEventListener('previous-page',
+          () => this._handlePreviousPage());
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._loadPreferences();
-    },
+    }
 
     _paramsChanged(value) {
       if (value.view !== Gerrit.Nav.View.SEARCH) { return; }
@@ -153,25 +166,27 @@
       // in an async so that attachment to the DOM can take place first.
       this.async(() => this.fire('title-change', {title: this._query}));
 
-      this._getPreferences().then(prefs => {
-        this._changesPerPage = prefs.changes_per_page;
-        return this._getChanges();
-      }).then(changes => {
-        changes = changes || [];
-        if (this._query && changes.length === 1) {
-          for (const query in LookupQueryPatterns) {
-            if (LookupQueryPatterns.hasOwnProperty(query) &&
+      this._getPreferences()
+          .then(prefs => {
+            this._changesPerPage = prefs.changes_per_page;
+            return this._getChanges();
+          })
+          .then(changes => {
+            changes = changes || [];
+            if (this._query && changes.length === 1) {
+              for (const query in LookupQueryPatterns) {
+                if (LookupQueryPatterns.hasOwnProperty(query) &&
                 this._query.match(LookupQueryPatterns[query])) {
-              this._replaceCurrentLocation(
-                  Gerrit.Nav.getUrlForChange(changes[0]));
-              return;
+                  this._replaceCurrentLocation(
+                      Gerrit.Nav.getUrlForChange(changes[0]));
+                  return;
+                }
+              }
             }
-          }
-        }
-        this._changes = changes;
-        this._loading = false;
-      });
-    },
+            this._changes = changes;
+            this._loading = false;
+          });
+    }
 
     _loadPreferences() {
       return this.$.restAPI.getLoggedIn().then(loggedIn => {
@@ -183,20 +198,20 @@
           this.preferences = {};
         }
       });
-    },
+    }
 
     _replaceCurrentLocation(url) {
       window.location.replace(url);
-    },
+    }
 
     _getChanges() {
       return this.$.restAPI.getChanges(this._changesPerPage, this._query,
           this._offset);
-    },
+    }
 
     _getPreferences() {
       return this.$.restAPI.getPreferences();
-    },
+    }
 
     _limitFor(query, defaultLimit) {
       const match = query.match(LIMIT_OPERATOR_PATTERN);
@@ -204,7 +219,7 @@
         return defaultLimit;
       }
       return parseInt(match[1], 10);
-    },
+    }
 
     _computeNavLink(query, offset, direction, changesPerPage) {
       // Offset could be a string when passed from the router.
@@ -212,32 +227,32 @@
       const limit = this._limitFor(query, changesPerPage);
       const newOffset = Math.max(0, offset + (limit * direction));
       return Gerrit.Nav.getUrlForSearchQuery(query, newOffset);
-    },
+    }
 
     _computePrevArrowClass(offset) {
       return offset === 0 ? 'hide' : '';
-    },
+    }
 
     _computeNextArrowClass(changes) {
       const more = changes.length && changes[changes.length - 1]._more_changes;
       return more ? '' : 'hide';
-    },
+    }
 
     _computeNavClass(loading) {
       return loading || !this._changes || !this._changes.length ? 'hide' : '';
-    },
+    }
 
     _handleNextPage() {
       if (this.$.nextArrow.hidden) { return; }
       page.show(this._computeNavLink(
           this._query, this._offset, 1, this._changesPerPage));
-    },
+    }
 
     _handlePreviousPage() {
       if (this.$.prevArrow.hidden) { return; }
       page.show(this._computeNavLink(
           this._query, this._offset, -1, this._changesPerPage));
-    },
+    }
 
     _changesChanged(changes) {
       this._userId = null;
@@ -256,28 +271,30 @@
       if (REPO_QUERY_PATTERN.test(this._query)) {
         this._repo = changes[0].project;
       }
-    },
+    }
 
     _computeHeaderClass(id) {
       return id ? '' : 'hide';
-    },
+    }
 
     _computePage(offset, changesPerPage) {
       return offset / changesPerPage + 1;
-    },
+    }
 
     _computeLoggedIn(account) {
       return !!(account && Object.keys(account).length > 0);
-    },
+    }
 
     _handleToggleStar(e) {
       this.$.restAPI.saveChangeStarred(e.detail.change._number,
           e.detail.starred);
-    },
+    }
 
     _handleToggleReviewed(e) {
       this.$.restAPI.saveChangeReviewed(e.detail.change._number,
           e.detail.reviewed);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeListView.is, GrChangeListView);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 2367aac..08a83af 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 699f07a..8fdb90c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -31,7 +31,9 @@
 
 <dom-module id="gr-change-list">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-change-list-styles">
       #changeList {
         border-collapse: collapse;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 5006f1e..8fd4214 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -22,9 +22,26 @@
   const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
   const MAX_SHORTCUT_CHARS = 5;
 
-  Polymer({
-    is: 'gr-change-list',
-
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.ChangeTableMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeList extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.ChangeTableBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.RESTClientBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-list'; }
     /**
      * Fired when next page key shortcut was pressed.
      *
@@ -37,87 +54,74 @@
      * @event previous-page
      */
 
-    hostAttributes: {
-      tabindex: 0,
-    },
-
-    properties: {
+    static get properties() {
+      return {
       /**
        * The logged-in user's account, or an empty object if no user is logged
        * in.
        */
-      account: {
-        type: Object,
-        value: null,
-      },
-      /**
-       * An array of ChangeInfo objects to render.
-       * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-       */
-      changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-      /**
-       * ChangeInfo objects grouped into arrays. The sections and changes
-       * properties should not be used together.
-       *
-       * @type {!Array<{
-       *   name: string,
-       *   query: string,
-       *   results: !Array<!Object>
-       * }>}
-       */
-      sections: {
-        type: Array,
-        value() { return []; },
-      },
-      labelNames: {
-        type: Array,
-        computed: '_computeLabelNames(sections)',
-      },
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      showNumber: Boolean, // No default value to prevent flickering.
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showReviewedState: {
-        type: Boolean,
-        value: false,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      changeTableColumns: Array,
-      visibleChangeTableColumns: Array,
-      preferences: Object,
-    },
+        account: {
+          type: Object,
+          value: null,
+        },
+        /**
+         * An array of ChangeInfo objects to render.
+         * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+         */
+        changes: {
+          type: Array,
+          observer: '_changesChanged',
+        },
+        /**
+         * ChangeInfo objects grouped into arrays. The sections and changes
+         * properties should not be used together.
+         *
+         * @type {!Array<{
+         *   name: string,
+         *   query: string,
+         *   results: !Array<!Object>
+         * }>}
+         */
+        sections: {
+          type: Array,
+          value() { return []; },
+        },
+        labelNames: {
+          type: Array,
+          computed: '_computeLabelNames(sections)',
+        },
+        _dynamicHeaderEndpoints: {
+          type: Array,
+        },
+        selectedIndex: {
+          type: Number,
+          notify: true,
+        },
+        showNumber: Boolean, // No default value to prevent flickering.
+        showStar: {
+          type: Boolean,
+          value: false,
+        },
+        showReviewedState: {
+          type: Boolean,
+          value: false,
+        },
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
+        },
+        changeTableColumns: Array,
+        visibleChangeTableColumns: Array,
+        preferences: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.ChangeTableBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.RESTClientBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    listeners: {
-      keydown: '_scopedKeydownHandler',
-    },
-
-    observers: [
-      '_sectionsChanged(sections.*)',
-      '_computePreferences(account, preferences)',
-    ],
+    static get observers() {
+      return [
+        '_sectionsChanged(sections.*)',
+        '_computePreferences(account, preferences)',
+      ];
+    }
 
     keyboardShortcuts() {
       return {
@@ -130,14 +134,29 @@
         [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
         [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
       };
-    },
+    }
 
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('keydown',
+          e => this._scopedKeydownHandler(e));
+    }
+
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('tabindex', 0);
+    }
+
+    /** @override */
     attached() {
+      super.attached();
       Gerrit.awaitPluginsLoaded().then(() => {
         this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
             'change-list-header');
       });
-    },
+    }
 
     /**
      * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
@@ -151,11 +170,11 @@
         // Enter.
         this._openChange(e);
       }
-    },
+    }
 
     _lowerCase(column) {
       return column.toLowerCase();
-    },
+    }
 
     _computePreferences(account, preferences) {
       // Polymer 2: check for undefined
@@ -168,20 +187,25 @@
       if (account) {
         this.showNumber = !!(preferences &&
             preferences.legacycid_in_change_table);
-        this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
-          this.getVisibleColumns(preferences.change_table) : this.columnNames;
+        if (preferences.change_table &&
+            preferences.change_table.length > 0) {
+          this.visibleChangeTableColumns =
+            this.getVisibleColumns(preferences.change_table);
+        } else {
+          this.visibleChangeTableColumns = this.columnNames;
+        }
       } else {
         // Not logged in.
         this.showNumber = false;
         this.visibleChangeTableColumns = this.columnNames;
       }
-    },
+    }
 
     _computeColspan(changeTableColumns, labelNames) {
       if (!changeTableColumns || !labelNames) return;
       return changeTableColumns.length + labelNames.length +
           NUMBER_FIXED_COLUMNS;
-    },
+    }
 
     _computeLabelNames(sections) {
       if (!sections) { return []; }
@@ -198,7 +222,7 @@
         }
       }
       return labels.sort();
-    },
+    }
 
     _computeLabelShortcut(labelName) {
       if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
@@ -210,26 +234,23 @@
             return a + i[0].toUpperCase();
           }, '')
           .slice(0, MAX_SHORTCUT_CHARS);
-    },
+    }
 
     _changesChanged(changes) {
       this.sections = changes ? [{results: changes}] : [];
-    },
+    }
 
     _processQuery(query) {
       let tokens = query.split(' ');
       const invalidTokens = ['limit:', 'age:', '-age:'];
-      tokens = tokens.filter(token => {
-        return !invalidTokens.some(invalidToken => {
-          return token.startsWith(invalidToken);
-        });
-      });
+      tokens = tokens.filter(token => !invalidTokens
+          .some(invalidToken => token.startsWith(invalidToken)));
       return tokens.join(' ');
-    },
+    }
 
     _sectionHref(query) {
       return Gerrit.Nav.getUrlForSearchQuery(this._processQuery(query));
-    },
+    }
 
     /**
      * Maps an index local to a particular section to the absolute index
@@ -245,19 +266,19 @@
         idx += this.sections[i].results.length;
       }
       return idx + localIndex;
-    },
+    }
 
     _computeItemSelected(sectionIndex, index, selectedIndex) {
       const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
       return idx == selectedIndex;
-    },
+    }
 
     _computeItemNeedsReview(account, change, showReviewedState) {
       return showReviewedState && !change.reviewed &&
           !change.work_in_progress &&
           this.changeIsOpen(change) &&
           (!account || account._account_id != change.owner._account_id);
-    },
+    }
 
     _computeItemHighlight(account, change) {
       // Do not show the assignee highlight if the change is not open.
@@ -267,7 +288,7 @@
         return false;
       }
       return account._account_id === change.assignee._account_id;
-    },
+    }
 
     _nextChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -275,7 +296,7 @@
 
       e.preventDefault();
       this.$.cursor.next();
-    },
+    }
 
     _prevChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -283,7 +304,7 @@
 
       e.preventDefault();
       this.$.cursor.previous();
-    },
+    }
 
     _openChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -291,7 +312,7 @@
 
       e.preventDefault();
       Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
-    },
+    }
 
     _nextPage(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -301,7 +322,7 @@
 
       e.preventDefault();
       this.fire('next-page');
-    },
+    }
 
     _prevPage(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -311,7 +332,7 @@
 
       e.preventDefault();
       this.fire('previous-page');
-    },
+    }
 
     _toggleChangeReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -319,7 +340,7 @@
 
       e.preventDefault();
       this._toggleReviewedForIndex(this.selectedIndex);
-    },
+    }
 
     _toggleReviewedForIndex(index) {
       const changeEls = this._getListItems();
@@ -329,18 +350,18 @@
 
       const changeEl = changeEls[index];
       changeEl.toggleReviewed();
-    },
+    }
 
     _refreshChangeList(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._reloadWindow();
-    },
+    }
 
     _reloadWindow() {
       window.location.reload();
-    },
+    }
 
     _toggleChangeStar(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -348,7 +369,7 @@
 
       e.preventDefault();
       this._toggleStarForIndex(this.selectedIndex);
-    },
+    }
 
     _toggleStarForIndex(index) {
       const changeEls = this._getListItems();
@@ -358,7 +379,7 @@
 
       const changeEl = changeEls[index];
       changeEl.$$('gr-change-star').toggleStar();
-    },
+    }
 
     _changeForIndex(index) {
       const changeEls = this._getListItems();
@@ -366,12 +387,12 @@
         return changeEls[index].change;
       }
       return null;
-    },
+    }
 
     _getListItems() {
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('gr-change-list-item'));
-    },
+    }
 
     _sectionsChanged() {
       // Flush DOM operations so that the list item elements will be loaded.
@@ -379,14 +400,16 @@
         this.$.cursor.stops = this._getListItems();
         this.$.cursor.moveToStart();
       });
-    },
+    }
 
     _isOutgoing(section) {
       return !!section.isOutgoing;
-    },
+    }
 
     _isEmpty(section) {
       return !section.results.length;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeList.is, GrChangeList);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 817de6f..75fd167 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
index 19e7a25..64d2486 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
@@ -17,8 +17,11 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-create-change-help',
+  /** @extends Polymer.Element */
+  class GrCreateChangeHelp extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-create-change-help'; }
 
     /**
      * Fired when the "Create change" button is tapped.
@@ -30,6 +33,8 @@
       e.preventDefault();
       this.dispatchEvent(
           new CustomEvent('create-tap', {bubbles: true, composed: true}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreateChangeHelp.is, GrCreateChangeHelp);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
index c43d62a..f10fbf2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-change-help</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
index 5abb257..7abd784 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
@@ -23,37 +23,44 @@
     PUSH_PREFIX: 'git push origin HEAD:refs/for/',
   };
 
-  Polymer({
-    is: 'gr-create-commands-dialog',
+  /** @extends Polymer.Element */
+  class GrCreateCommandsDialog extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-create-commands-dialog'; }
 
-    properties: {
-      branch: String,
-      _createNewCommitCommand: {
-        type: String,
-        readonly: true,
-        value: Commands.CREATE,
-      },
-      _amendExistingCommitCommand: {
-        type: String,
-        readonly: true,
-        value: Commands.AMEND,
-      },
-      _pushCommand: {
-        type: String,
-        computed: '_computePushCommand(branch)',
-      },
-    },
+    static get properties() {
+      return {
+        branch: String,
+        _createNewCommitCommand: {
+          type: String,
+          readonly: true,
+          value: Commands.CREATE,
+        },
+        _amendExistingCommitCommand: {
+          type: String,
+          readonly: true,
+          value: Commands.AMEND,
+        },
+        _pushCommand: {
+          type: String,
+          computed: '_computePushCommand(branch)',
+        },
+      };
+    }
 
     open() {
       this.$.commandsOverlay.open();
-    },
+    }
 
     _handleClose() {
       this.$.commandsOverlay.close();
-    },
+    }
 
     _computePushCommand(branch) {
       return Commands.PUSH_PREFIX + branch;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreateCommandsDialog.is, GrCreateCommandsDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
index 89ad573..2228b50 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-create-commands-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
index c2bfbf5..35f7450 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
@@ -22,29 +22,34 @@
    * name and the branch name.
    *
    * @event confirm
+   * @extends Polymer.Element
    */
+  class GrCreateDestinationDialog extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-create-destination-dialog'; }
 
-  Polymer({
-    is: 'gr-create-destination-dialog',
+    static get properties() {
+      return {
+        _repo: String,
+        _branch: String,
+        _repoAndBranchSelected: {
+          type: Boolean,
+          value: false,
+          computed: '_computeRepoAndBranchSelected(_repo, _branch)',
+        },
+      };
+    }
 
-    properties: {
-      _repo: String,
-      _branch: String,
-      _repoAndBranchSelected: {
-        type: Boolean,
-        value: false,
-        computed: '_computeRepoAndBranchSelected(_repo, _branch)',
-      },
-    },
     open() {
       this._repo = '';
       this._branch = '';
       this.$.createOverlay.open();
-    },
+    }
 
     _handleClose() {
       this.$.createOverlay.close();
-    },
+    }
 
     _pickerConfirm(e) {
       this.$.createOverlay.close();
@@ -54,10 +59,13 @@
       e.preventDefault();
       e.stopPropagation();
       this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
-    },
+    }
 
     _computeRepoAndBranchSelected(repo, branch) {
       return !!(repo && branch);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCreateDestinationDialog.is,
+      GrCreateDestinationDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index b762ab3..d0e1db2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -19,65 +19,73 @@
 
   const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
 
-  Polymer({
-    is: 'gr-dashboard-view',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrDashboardView extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-dashboard-view'; }
     /**
      * Fired when the title of the page should change.
      *
      * @event title-change
      */
 
-    properties: {
-      account: {
-        type: Object,
-        value: null,
-      },
-      preferences: Object,
-      /** @type {{ selectedChangeIndex: number }} */
-      viewState: Object,
-
-      /** @type {{ project: string, user: string }} */
-      params: {
-        type: Object,
-      },
-
-      createChangeTap: {
-        type: Function,
-        value() {
-          return this._createChangeTap.bind(this);
+    static get properties() {
+      return {
+        account: {
+          type: Object,
+          value: null,
         },
-      },
+        preferences: Object,
+        /** @type {{ selectedChangeIndex: number }} */
+        viewState: Object,
 
-      _results: Array,
+        /** @type {{ project: string, user: string }} */
+        params: {
+          type: Object,
+        },
 
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
+        createChangeTap: {
+          type: Function,
+          value() {
+            return this._createChangeTap.bind(this);
+          },
+        },
 
-      _showDraftsBanner: {
-        type: Boolean,
-        value: false,
-      },
+        _results: Array,
 
-      _showNewUserHelp: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        /**
+         * For showing a "loading..." string during ajax requests.
+         */
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
 
-    observers: [
-      '_paramsChanged(params.*)',
-    ],
+        _showDraftsBanner: {
+          type: Boolean,
+          value: false,
+        },
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+        _showNewUserHelp: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
+
+    static get observers() {
+      return [
+        '_paramsChanged(params.*)',
+      ];
+    }
 
     get options() {
       return this.listChangesOptionsToHex(
@@ -85,11 +93,13 @@
           this.ListChangesOption.DETAILED_ACCOUNTS,
           this.ListChangesOption.REVIEWED
       );
-    },
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._loadPreferences();
-    },
+    }
 
     _loadPreferences() {
       return this.$.restAPI.getLoggedIn().then(loggedIn => {
@@ -101,7 +111,7 @@
           this.preferences = {};
         }
       });
-    },
+    }
 
     _getProjectDashboard(project, dashboard) {
       const errFn = response => {
@@ -124,18 +134,18 @@
           }),
         };
       });
-    },
+    }
 
     _computeTitle(user) {
       if (!user || user === 'self') {
         return 'My Reviews';
       }
       return 'Dashboard for ' + user;
-    },
+    }
 
     _isViewActive(params) {
       return params.view === Gerrit.Nav.View.DASHBOARD;
-    },
+    }
 
     _paramsChanged(paramsChangeRecord) {
       const params = paramsChangeRecord.base;
@@ -145,7 +155,7 @@
       }
 
       return this._reload();
-    },
+    }
 
     /**
      * Reloads the element.
@@ -173,13 +183,15 @@
           .then(() => {
             this._maybeShowDraftsBanner();
             this.$.reporting.dashboardDisplayed();
-          }).catch(err => {
+          })
+          .catch(err => {
             this.fire('title-change', {
               title: title || this._computeTitle(user),
             });
             console.warn(err);
-          }).then(() => { this._loading = false; });
-    },
+          })
+          .then(() => { this._loading = false; });
+    }
 
     /**
      * Fetches the changes for each dashboard section and sets this._results
@@ -193,9 +205,9 @@
       if (!res) { return Promise.resolve(); }
 
       const queries = res.sections
-          .map(section => section.suffixForDashboard ?
+          .map(section => (section.suffixForDashboard ?
             section.query + ' ' + section.suffixForDashboard :
-            section.query);
+            section.query));
 
       if (checkForNewUser) {
         queries.push('owner:self limit:1');
@@ -208,17 +220,19 @@
               const lastResultSet = changes.pop();
               this._showNewUserHelp = lastResultSet.length == 0;
             }
-            this._results = changes.map((results, i) => ({
-              name: res.sections[i].name,
-              countLabel: this._computeSectionCountLabel(results),
-              query: res.sections[i].query,
-              results,
-              isOutgoing: res.sections[i].isOutgoing,
-            })).filter((section, i) => i < res.sections.length && (
+            this._results = changes.map((results, i) => {
+              return {
+                name: res.sections[i].name,
+                countLabel: this._computeSectionCountLabel(results),
+                query: res.sections[i].query,
+                results,
+                isOutgoing: res.sections[i].isOutgoing,
+              };
+            }).filter((section, i) => i < res.sections.length && (
               !res.sections[i].hideIfEmpty ||
                 section.results.length));
           });
-    },
+    }
 
     _computeSectionCountLabel(changes) {
       if (!changes || !changes.length || changes.length == 0) {
@@ -228,25 +242,25 @@
       const numChanges = changes.length;
       const andMore = more ? ' and more' : '';
       return `(${numChanges}${andMore})`;
-    },
+    }
 
     _computeUserHeaderClass(params) {
-      if (!params || !!params.project || !params.user
-          || params.user === 'self') {
+      if (!params || !!params.project || !params.user ||
+          params.user === 'self') {
         return 'hide';
       }
       return '';
-    },
+    }
 
     _handleToggleStar(e) {
       this.$.restAPI.saveChangeStarred(e.detail.change._number,
           e.detail.starred);
-    },
+    }
 
     _handleToggleReviewed(e) {
       this.$.restAPI.saveChangeReviewed(e.detail.change._number,
           e.detail.reviewed);
-    },
+    }
 
     /**
      * Banner is shown if a user is on their own dashboard and they have draft
@@ -265,15 +279,15 @@
       if (!closedChanges.length) { return; }
 
       this._showDraftsBanner = true;
-    },
+    }
 
     _computeBannerClass(show) {
       return show ? '' : 'hide';
-    },
+    }
 
     _handleOpenDeleteDialog() {
       this.$.confirmDeleteOverlay.open();
-    },
+    }
 
     _handleConfirmDelete() {
       this.$.confirmDeleteDialog.disabled = true;
@@ -281,23 +295,25 @@
         this._closeConfirmDeleteOverlay();
         this._reload();
       });
-    },
+    }
 
     _closeConfirmDeleteOverlay() {
       this.$.confirmDeleteOverlay.close();
-    },
+    }
 
     _computeDraftsLink() {
       return Gerrit.Nav.getUrlForSearchQuery('has:draft -is:open');
-    },
+    }
 
     _createChangeTap(e) {
       this.$.destinationDialog.open();
-    },
+    }
 
     _handleDestinationConfirm(e) {
       this.$.commandsDialog.branch = e.detail.branch;
       this.$.commandsDialog.open();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDashboardView.is, GrDashboardView);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 41d4192..0d15d13 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dashboard-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
index acc4295..de0a56e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
+++ b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
@@ -17,14 +17,21 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-embed-dashboard',
+  /** @extends Polymer.Element */
+  class GrEmbedDashboard extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-embed-dashboard'; }
 
-    properties: {
-      account: Object,
-      sections: Array,
-      preferences: Object,
-      showNewUserHelp: Boolean,
-    },
-  });
+    static get properties() {
+      return {
+        account: Object,
+        sections: Array,
+        preferences: Object,
+        showNewUserHelp: Boolean,
+      };
+    }
+  }
+
+  customElements.define(GrEmbedDashboard.is, GrEmbedDashboard);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
index 0b4459c..5d4b8a3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.html
@@ -24,8 +24,12 @@
 
 <dom-module id="gr-repo-header">
   <template>
-    <style include="shared-styles"></style>
-    <style include="dashboard-header-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="dashboard-header-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="info">
       <h1 class$="name">
         [[repo]]
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
index 7ae4dab..c0e472a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
@@ -17,18 +17,23 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-repo-header',
+  /** @extends Polymer.Element */
+  class GrRepoHeader extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-repo-header'; }
 
-    properties: {
-      /** @type {?String} */
-      repo: {
-        type: String,
-        observer: '_repoChanged',
-      },
-      /** @type {String|null} */
-      _repoUrl: String,
-    },
+    static get properties() {
+      return {
+      /** @type {?string} */
+        repo: {
+          type: String,
+          observer: '_repoChanged',
+        },
+        /** @type {string|null} */
+        _repoUrl: String,
+      };
+    }
 
     _repoChanged(repoName) {
       if (!repoName) {
@@ -36,6 +41,8 @@
         return;
       }
       this._repoUrl = Gerrit.Nav.getUrlForRepo(repoName);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoHeader.is, GrRepoHeader);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
index 266818e..85eb509 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-header</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
index fed1c12..8175849 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -27,7 +27,9 @@
 
 <dom-module id="gr-user-header">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="dashboard-header-styles">
       .name {
         display: inline-block;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
index 6afc169..2fc8170 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -17,40 +17,47 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-user-header',
+  /**
+   * @extends Polymer.Element
+   */
+  class GrUserHeader extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-user-header'; }
 
-    properties: {
-      /** @type {?String} */
-      userId: {
-        type: String,
-        observer: '_accountChanged',
-      },
+    static get properties() {
+      return {
+      /** @type {?string} */
+        userId: {
+          type: String,
+          observer: '_accountChanged',
+        },
 
-      showDashboardLink: {
-        type: Boolean,
-        value: false,
-      },
+        showDashboardLink: {
+          type: Boolean,
+          value: false,
+        },
 
-      loggedIn: {
-        type: Boolean,
-        value: false,
-      },
+        loggedIn: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * @type {?{name: ?, email: ?, registered_on: ?}}
-       */
-      _accountDetails: {
-        type: Object,
-        value: null,
-      },
+        /**
+         * @type {?{name: ?, email: ?, registered_on: ?}}
+         */
+        _accountDetails: {
+          type: Object,
+          value: null,
+        },
 
-      /** @type {?String} */
-      _status: {
-        type: String,
-        value: null,
-      },
-    },
+        /** @type {?string} */
+        _status: {
+          type: String,
+          value: null,
+        },
+      };
+    }
 
     _accountChanged(userId) {
       if (!userId) {
@@ -65,19 +72,19 @@
       this.$.restAPI.getAccountStatus(userId).then(status => {
         this._status = status;
       });
-    },
+    }
 
     _computeDisplayClass(status) {
       return status ? ' ' : 'hide';
-    },
+    }
 
     _computeDetail(accountDetails, name) {
       return accountDetails ? accountDetails[name] : '';
-    },
+    }
 
     _computeStatusClass(accountDetails) {
       return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
-    },
+    }
 
     _computeDashboardUrl(accountDetails) {
       if (!accountDetails) { return null; }
@@ -85,11 +92,13 @@
       const email = accountDetails.email;
       if (!id && !email ) { return null; }
       return Gerrit.Nav.getUrlForUserDashboard(id ? id : email);
-    },
+    }
 
     _computeDashboardLinkClass(showDashboardLink, loggedIn) {
       return showDashboardLink && loggedIn ?
         'dashboardLink' : 'dashboardLink hide';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrUserHeader.is, GrUserHeader);
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
index e837a5b..9e04ecd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-user-header</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 000756f..f4bd6a6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -36,6 +36,7 @@
 <link rel="import" href="../gr-confirm-move-dialog/gr-confirm-move-dialog.html">
 <link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
 <link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
+<link rel="import" href="../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html">
 <link rel="import" href="../gr-confirm-submit-dialog/gr-confirm-submit-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -192,12 +193,8 @@
           hidden></gr-confirm-cherrypick-dialog>
       <gr-confirm-cherrypick-conflict-dialog id="confirmCherrypickConflict"
           class="confirmDialog"
-          change-status="[[changeStatus]]"
-          commit-message="[[commitMessage]]"
-          commit-num="[[commitNum]]"
           on-confirm="_handleCherrypickConflictConfirm"
           on-cancel="_handleConfirmDialogCancel"
-          project="[[change.project]]"
           hidden></gr-confirm-cherrypick-conflict-dialog>
       <gr-confirm-move-dialog id="confirmMove"
           class="confirmDialog"
@@ -210,6 +207,12 @@
           on-confirm="_handleRevertDialogConfirm"
           on-cancel="_handleConfirmDialogCancel"
           hidden></gr-confirm-revert-dialog>
+      <gr-confirm-revert-submission-dialog id="confirmRevertSubmissionDialog"
+          class="confirmDialog"
+          commit-message="[[commitMessage]]"
+          on-confirm="_handleRevertSubmissionDialogConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          hidden></gr-confirm-revert-submission-dialog>
       <gr-confirm-abandon-dialog id="confirmAbandonDialog"
           class="confirmDialog"
           on-confirm="_handleAbandonDialogConfirm"
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 88d2f76..299c12a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -64,6 +64,7 @@
     REBASE_EDIT: 'rebaseEdit',
     RESTORE: 'restore',
     REVERT: 'revert',
+    REVERT_SUBMISSION: 'revert_submission',
     REVIEWED: 'reviewed',
     STOP_EDIT: 'stopEdit',
     UNIGNORE: 'unignore',
@@ -86,6 +87,7 @@
     rebase: 'Rebasing...',
     restore: 'Restoring...',
     revert: 'Reverting...',
+    revert_submission: 'Reverting Submission...',
     submit: 'Submitting...',
   };
 
@@ -180,6 +182,7 @@
     ChangeActions.REBASE_EDIT,
     ChangeActions.RESTORE,
     ChangeActions.REVERT,
+    ChangeActions.REVERT_SUBMISSION,
     ChangeActions.STOP_EDIT,
     QUICK_APPROVE_ACTION.key,
     RevisionActions.REBASE,
@@ -189,9 +192,25 @@
   const AWAIT_CHANGE_ATTEMPTS = 5;
   const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
-  Polymer({
-    is: 'gr-change-actions',
+  const REVERT_TYPES = {
+    REVERT_SINGLE_CHANGE: 1,
+    REVERT_SUBMISSION: 2,
+  };
 
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeActions extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-actions'; }
     /**
      * Fired when the change should be reloaded.
      *
@@ -216,7 +235,15 @@
      * @event show-error
      */
 
-    properties: {
+    constructor() {
+      super();
+      this.ActionType = ActionType;
+      this.ChangeActions = ChangeActions;
+      this.RevisionActions = RevisionActions;
+    }
+
+    static get properties() {
+      return {
       /**
        * @type {{
        *    _number: number,
@@ -226,217 +253,217 @@
        *    subject: string,
        *  }}
        */
-      change: Object,
-      actions: {
-        type: Object,
-        value() { return {}; },
-      },
-      primaryActionKeys: {
-        type: Array,
-        value() {
-          return [
-            RevisionActions.SUBMIT,
-          ];
+        change: Object,
+        actions: {
+          type: Object,
+          value() { return {}; },
         },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      _hasKnownChainState: {
-        type: Boolean,
-        value: false,
-      },
-      _hideQuickApproveAction: {
-        type: Boolean,
-        value: false,
-      },
-      changeNum: String,
-      changeStatus: String,
-      commitNum: String,
-      hasParent: {
-        type: Boolean,
-        observer: '_computeChainState',
-      },
-      latestPatchNum: String,
-      commitMessage: {
-        type: String,
-        value: '',
-      },
-      /** @type {?} */
-      revisionActions: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-      // If property binds directly to [[revisionActions.submit]] it is not
-      // updated when revisionActions doesn't contain submit action.
-      /** @type {?} */
-      _revisionSubmitAction: {
-        type: Object,
-        computed: '_getSubmitAction(revisionActions)',
-      },
-      // If property binds directly to [[revisionActions.rebase]] it is not
-      // updated when revisionActions doesn't contain rebase action.
-      /** @type {?} */
-      _revisionRebaseAction: {
-        type: Object,
-        computed: '_getRebaseAction(revisionActions)',
-      },
-      privateByDefault: String,
+        primaryActionKeys: {
+          type: Array,
+          value() {
+            return [
+              RevisionActions.SUBMIT,
+            ];
+          },
+        },
+        disableEdit: {
+          type: Boolean,
+          value: false,
+        },
+        _hasKnownChainState: {
+          type: Boolean,
+          value: false,
+        },
+        _hideQuickApproveAction: {
+          type: Boolean,
+          value: false,
+        },
+        changeNum: String,
+        changeStatus: String,
+        commitNum: String,
+        hasParent: {
+          type: Boolean,
+          observer: '_computeChainState',
+        },
+        latestPatchNum: String,
+        commitMessage: {
+          type: String,
+          value: '',
+        },
+        /** @type {?} */
+        revisionActions: {
+          type: Object,
+          notify: true,
+          value() { return {}; },
+        },
+        // If property binds directly to [[revisionActions.submit]] it is not
+        // updated when revisionActions doesn't contain submit action.
+        /** @type {?} */
+        _revisionSubmitAction: {
+          type: Object,
+          computed: '_getSubmitAction(revisionActions)',
+        },
+        // If property binds directly to [[revisionActions.rebase]] it is not
+        // updated when revisionActions doesn't contain rebase action.
+        /** @type {?} */
+        _revisionRebaseAction: {
+          type: Object,
+          computed: '_getRebaseAction(revisionActions)',
+        },
+        privateByDefault: String,
 
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _actionLoadingMessage: {
-        type: String,
-        value: '',
-      },
-      _allActionValues: {
-        type: Array,
-        computed: '_computeAllActions(actions.*, revisionActions.*,' +
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _actionLoadingMessage: {
+          type: String,
+          value: '',
+        },
+        _allActionValues: {
+          type: Array,
+          computed: '_computeAllActions(actions.*, revisionActions.*,' +
             'primaryActionKeys.*, _additionalActions.*, change, ' +
             '_actionPriorityOverrides.*)',
-      },
-      _topLevelActions: {
-        type: Array,
-        computed: '_computeTopLevelActions(_allActionValues.*, ' +
-            '_hiddenActions.*, _overflowActions.*)',
-        observer: '_filterPrimaryActions',
-      },
-      _topLevelPrimaryActions: Array,
-      _topLevelSecondaryActions: Array,
-      _menuActions: {
-        type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' +
-            '_overflowActions.*)',
-      },
-      _overflowActions: {
-        type: Array,
-        value() {
-          const value = [
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.WIP,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.DELETE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.CHERRYPICK,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.MOVE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.DOWNLOAD,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.IGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNIGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.REVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNREVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE_DELETE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.FOLLOW_UP,
-            },
-          ];
-          return value;
         },
-      },
-      _actionPriorityOverrides: {
-        type: Array,
-        value() { return []; },
-      },
-      _additionalActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _hiddenActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _disabledMenuActions: {
-        type: Array,
-        value() { return []; },
-      },
-      // editPatchsetLoaded == "does the current selected patch range have
-      // 'edit' as one of either basePatchNum or patchNum".
-      editPatchsetLoaded: {
-        type: Boolean,
-        value: false,
-      },
-      // editMode == "is edit mode enabled in the file list".
-      editMode: {
-        type: Boolean,
-        value: false,
-      },
-      editBasedOnCurrentPatchSet: {
-        type: Boolean,
-        value: true,
-      },
-    },
+        _topLevelActions: {
+          type: Array,
+          computed: '_computeTopLevelActions(_allActionValues.*, ' +
+            '_hiddenActions.*, _overflowActions.*)',
+          observer: '_filterPrimaryActions',
+        },
+        _topLevelPrimaryActions: Array,
+        _topLevelSecondaryActions: Array,
+        _menuActions: {
+          type: Array,
+          computed: '_computeMenuActions(_allActionValues.*, ' +
+            '_hiddenActions.*, _overflowActions.*)',
+        },
+        _overflowActions: {
+          type: Array,
+          value() {
+            const value = [
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.WIP,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.DELETE,
+              },
+              {
+                type: ActionType.REVISION,
+                key: RevisionActions.CHERRYPICK,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.MOVE,
+              },
+              {
+                type: ActionType.REVISION,
+                key: RevisionActions.DOWNLOAD,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.IGNORE,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.UNIGNORE,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.REVIEWED,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.UNREVIEWED,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.PRIVATE,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.PRIVATE_DELETE,
+              },
+              {
+                type: ActionType.CHANGE,
+                key: ChangeActions.FOLLOW_UP,
+              },
+            ];
+            return value;
+          },
+        },
+        _actionPriorityOverrides: {
+          type: Array,
+          value() { return []; },
+        },
+        _additionalActions: {
+          type: Array,
+          value() { return []; },
+        },
+        _hiddenActions: {
+          type: Array,
+          value() { return []; },
+        },
+        _disabledMenuActions: {
+          type: Array,
+          value() { return []; },
+        },
+        // editPatchsetLoaded == "does the current selected patch range have
+        // 'edit' as one of either basePatchNum or patchNum".
+        editPatchsetLoaded: {
+          type: Boolean,
+          value: false,
+        },
+        // editMode == "is edit mode enabled in the file list".
+        editMode: {
+          type: Boolean,
+          value: false,
+        },
+        editBasedOnCurrentPatchSet: {
+          type: Boolean,
+          value: true,
+        },
+        _revertChanges: Array,
+      };
+    }
 
-    ActionType,
-    ChangeActions,
-    RevisionActions,
-
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
-      '_changeChanged(change)',
-      '_editStatusChanged(editMode, editPatchsetLoaded, ' +
+    static get observers() {
+      return [
+        '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
+        '_changeChanged(change)',
+        '_editStatusChanged(editMode, editPatchsetLoaded, ' +
           'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
-    ],
+      ];
+    }
 
-    listeners: {
-      'fullscreen-overlay-opened': '_handleHideBackgroundContent',
-      'fullscreen-overlay-closed': '_handleShowBackgroundContent',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('fullscreen-overlay-opened',
+          () => this._handleHideBackgroundContent());
+      this.addEventListener('fullscreen-overlay-closed',
+          () => this._handleShowBackgroundContent());
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
       this._handleLoadingComplete();
-    },
+    }
 
     _getSubmitAction(revisionActions) {
       return this._getRevisionAction(revisionActions, 'submit', null);
-    },
+    }
 
     _getRebaseAction(revisionActions) {
       return this._getRevisionAction(revisionActions, 'rebase',
           {rebaseOnCurrent: null}
       );
-    },
+    }
 
     _getRevisionAction(revisionActions, actionName, emptyActionValue) {
       if (!revisionActions) {
@@ -448,7 +475,7 @@
         return emptyActionValue;
       }
       return revisionActions[actionName];
-    },
+    }
 
     reload() {
       if (!this.changeNum || !this.latestPatchNum) {
@@ -456,21 +483,23 @@
       }
 
       this._loading = true;
-      return this._getRevisionActions().then(revisionActions => {
-        if (!revisionActions) { return; }
+      return this._getRevisionActions()
+          .then(revisionActions => {
+            if (!revisionActions) { return; }
 
-        this.revisionActions = this._updateRebaseAction(revisionActions);
-        this._handleLoadingComplete();
-      }).catch(err => {
-        this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
-        this._loading = false;
-        throw err;
-      });
-    },
+            this.revisionActions = this._updateRebaseAction(revisionActions);
+            this._handleLoadingComplete();
+          })
+          .catch(err => {
+            this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
+            this._loading = false;
+            throw err;
+          });
+    }
 
     _handleLoadingComplete() {
       Gerrit.awaitPluginsLoaded().then(() => this._loading = false);
-    },
+    }
 
     _updateRebaseAction(revisionActions) {
       if (revisionActions && revisionActions.rebase) {
@@ -482,11 +511,11 @@
         this._parentIsCurrent = true;
       }
       return revisionActions;
-    },
+    }
 
     _changeChanged() {
       this.reload();
-    },
+    }
 
     addActionButton(type, label) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
@@ -497,11 +526,12 @@
         label,
         __type: type,
         __key: ADDITIONAL_ACTION_KEY_PREFIX +
-            Math.random().toString(36).substr(2),
+            Math.random().toString(36)
+                .substr(2),
       };
       this.push('_additionalActions', action);
       return action.__key;
-    },
+    }
 
     removeActionButton(key) {
       const idx = this._indexOfActionButtonWithKey(key);
@@ -509,7 +539,7 @@
         return;
       }
       this.splice('_additionalActions', idx, 1);
-    },
+    }
 
     setActionButtonProp(key, prop, value) {
       this.set([
@@ -517,7 +547,7 @@
         this._indexOfActionButtonWithKey(key),
         prop,
       ], value);
-    },
+    }
 
     setActionOverflow(type, key, overflow) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
@@ -534,15 +564,14 @@
       } else if (overflow) {
         this.push('_overflowActions', action);
       }
-    },
+    }
 
     setActionPriority(type, key, priority) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
         throw Error(`Invalid action type given: ${type}`);
       }
-      const index = this._actionPriorityOverrides.findIndex(action => {
-        return action.type === type && action.key === key;
-      });
+      const index = this._actionPriorityOverrides
+          .findIndex(action => action.type === type && action.key === key);
       const action = {
         type,
         key,
@@ -553,7 +582,7 @@
       } else {
         this.push('_actionPriorityOverrides', action);
       }
-    },
+    }
 
     setActionHidden(type, key, hidden) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
@@ -566,7 +595,7 @@
       } else if (!hidden && idx !== -1) {
         this.splice('_hiddenActions', idx, 1);
       }
-    },
+    }
 
     getActionDetails(action) {
       if (this.revisionActions[action]) {
@@ -574,7 +603,7 @@
       } else if (this.actions[action]) {
         return this.actions[action];
       }
-    },
+    }
 
     _indexOfActionButtonWithKey(key) {
       for (let i = 0; i < this._additionalActions.length; i++) {
@@ -583,20 +612,20 @@
         }
       }
       return -1;
-    },
+    }
 
     _getRevisionActions() {
       return this.$.restAPI.getChangeRevisionActions(this.changeNum,
           this.latestPatchNum);
-    },
+    }
 
     _shouldHideActions(actions, loading) {
       return loading || !actions || !actions.base || !actions.base.length;
-    },
+    }
 
     _keyCount(changeRecord) {
       return Object.keys((changeRecord && changeRecord.base) || {}).length;
-    },
+    }
 
     _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
         additionalActionsChangeRecord) {
@@ -623,7 +652,7 @@
           this.set('revisionActions.download', DOWNLOAD_ACTION);
         }
       }
-    },
+    }
 
     /**
      * @param {string=} actionName
@@ -635,7 +664,7 @@
         // see https://github.com/Polymer/polymer/issues/2631
         this.notifyPath('actions.' + actionName, false);
       }
-    },
+    }
 
     _editStatusChanged(editMode, editPatchsetLoaded,
         editBasedOnCurrentPatchSet, disableEdit) {
@@ -702,13 +731,11 @@
         // Remove edit button.
         this._deleteAndNotify('edit');
       }
-    },
+    }
 
     _getValuesFor(obj) {
-      return Object.keys(obj).map(key => {
-        return obj[key];
-      });
-    },
+      return Object.keys(obj).map(key => obj[key]);
+    }
 
     _getLabelStatus(label) {
       if (label.approved) {
@@ -720,7 +747,7 @@
       } else {
         return LabelStatus.NEED;
       }
-    },
+    }
 
     /**
      * Get highest score for last missing permitted label for current change.
@@ -768,15 +795,14 @@
         }
       }
       return null;
-    },
+    }
 
     hideQuickApproveAction() {
       this._topLevelSecondaryActions =
-        this._topLevelSecondaryActions.filter(sa => {
-          return sa.key !== QUICK_APPROVE_ACTION.key;
-        });
+        this._topLevelSecondaryActions
+            .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
       this._hideQuickApproveAction = true;
-    },
+    }
 
     _getQuickApproveAction() {
       if (this._hideQuickApproveAction) {
@@ -795,7 +821,7 @@
       review.labels[approval.label] = approval.score;
       action.payload = review;
       return action;
-    },
+    }
 
     _getActionValues(actionsChangeRecord, primariesChangeRecord,
         additionalActionsChangeRecord, type) {
@@ -832,15 +858,15 @@
 
       let additionalActions = (additionalActionsChangeRecord &&
       additionalActionsChangeRecord.base) || [];
-      additionalActions = additionalActions.filter(a => {
-        return a.__type === type;
-      }).map(a => {
-        a.__primary = primaryActionKeys.includes(a.__key);
-        // Triggers a re-render by ensuring object inequality.
-        return Object.assign({}, a);
-      });
+      additionalActions = additionalActions
+          .filter(a => a.__type === type)
+          .map(a => {
+            a.__primary = primaryActionKeys.includes(a.__key);
+            // Triggers a re-render by ensuring object inequality.
+            return Object.assign({}, a);
+          });
       return result.concat(additionalActions).concat(pluginActions);
-    },
+    }
 
     _populateActionUrl(action) {
       const patchNum =
@@ -848,7 +874,7 @@
       this.$.restAPI.getChangeActionURL(
           this.changeNum, patchNum, '/' + action.__key)
           .then(url => action.__url = url);
-    },
+    }
 
     /**
      * Given a change action, return a display label that uses the appropriate
@@ -864,7 +890,7 @@
       }
       // Otherwise, just map the name to sentence case.
       return this._toSentenceCase(action.label);
-    },
+    }
 
     /**
      * Capitalize the first letter and lowecase all others.
@@ -875,16 +901,16 @@
     _toSentenceCase(s) {
       if (!s.length) { return ''; }
       return s[0].toUpperCase() + s.slice(1).toLowerCase();
-    },
+    }
 
     _computeLoadingLabel(action) {
       return ActionLoadingLabels[action] || 'Working...';
-    },
+    }
 
     _canSubmitChange() {
       return this.$.jsAPI.canSubmitChange(this.change,
           this._getRevision(this.change, this.latestPatchNum));
-    },
+    }
 
     _getRevision(change, patchNum) {
       for (const rev of Object.values(change.revisions)) {
@@ -893,19 +919,31 @@
         }
       }
       return null;
-    },
-
-    _modifyRevertMsg() {
-      return this.$.jsAPI.modifyRevertMsg(this.change,
-          this.$.confirmRevertDialog.message, this.commitMessage);
-    },
+    }
 
     showRevertDialog() {
-      this.$.confirmRevertDialog.populateRevertMessage(
-          this.commitMessage, this.change.current_revision);
-      this.$.confirmRevertDialog.message = this._modifyRevertMsg();
-      this._showActionDialog(this.$.confirmRevertDialog);
-    },
+      const query = 'submissionid:' + this.change.submission_id;
+      /* A chromium plugin expects that the modifyRevertMsg hook will only
+      be called after the revert button is pressed, hence we populate the
+      revert dialog after revert button is pressed. */
+      this.$.restAPI.getChanges('', query)
+          .then(changes => {
+            this.$.confirmRevertDialog.populate(this.change,
+                this.commitMessage, changes);
+            this._showActionDialog(this.$.confirmRevertDialog);
+          });
+    }
+
+    showRevertSubmissionDialog() {
+      const query = 'submissionid:' + this.change.submission_id;
+      this.$.restAPI.getChanges('', query)
+          .then(changes => {
+            this.$.confirmRevertSubmissionDialog.
+                _populateRevertSubmissionMessage(
+                    this.commitMessage, this.change, changes);
+            this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+          });
+    }
 
     _handleActionTap(e) {
       e.preventDefault();
@@ -923,7 +961,7 @@
       }
       const type = el.getAttribute('data-action-type');
       this._handleAction(type, key);
-    },
+    }
 
     _handleOveflowItemTap(e) {
       e.preventDefault();
@@ -935,7 +973,7 @@
         return;
       }
       this._handleAction(e.detail.action.__type, e.detail.action.__key);
-    },
+    }
 
     _handleAction(type, key) {
       this.$.reporting.reportInteraction(`${type}-${key}`);
@@ -949,7 +987,7 @@
         default:
           this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
-    },
+    }
 
     _handleChangeAction(key) {
       let action;
@@ -957,13 +995,14 @@
         case ChangeActions.REVERT:
           this.showRevertDialog();
           break;
+        case ChangeActions.REVERT_SUBMISSION:
+          this.showRevertSubmissionDialog();
+          break;
         case ChangeActions.ABANDON:
           this._showActionDialog(this.$.confirmAbandonDialog);
           break;
         case QUICK_APPROVE_ACTION.key:
-          action = this._allActionValues.find(o => {
-            return o.key === key;
-          });
+          action = this._allActionValues.find(o => o.key === key);
           this._fireAction(
               this._prependSlash(key), action, true, action.payload);
           break;
@@ -997,7 +1036,7 @@
         default:
           this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
-    },
+    }
 
     _handleRevisionAction(key) {
       switch (key) {
@@ -1019,11 +1058,11 @@
           this._fireAction(this._prependSlash(key),
               this.revisionActions[key], true);
       }
-    },
+    }
 
     _prependSlash(key) {
       return key === '/' ? key : `/${key}`;
-    },
+    }
 
     /**
      * _hasKnownChainState set to true true if hasParent is defined (can be
@@ -1031,25 +1070,25 @@
      */
     _computeChainState(hasParent) {
       this._hasKnownChainState = true;
-    },
+    }
 
     _calculateDisabled(action, hasKnownChainState) {
       if (action.__key === 'rebase' && hasKnownChainState === false) {
         return true;
       }
       return !action.enabled;
-    },
+    }
 
     _handleConfirmDialogCancel() {
       this._hideAllDialogs();
-    },
+    }
 
     _hideAllDialogs() {
       const dialogEls =
           Polymer.dom(this.root).querySelectorAll('.confirmDialog');
       for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
       this.$.overlay.close();
-    },
+    }
 
     _handleRebaseConfirm(e) {
       const el = this.$.confirmRebase;
@@ -1057,15 +1096,15 @@
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
-    },
+    }
 
     _handleCherrypickConfirm() {
       this._handleCherryPickRestApi(false);
-    },
+    }
 
     _handleCherrypickConflictConfirm() {
       this._handleCherryPickRestApi(true);
-    },
+    }
 
     _handleCherryPickRestApi(conflicts) {
       const el = this.$.confirmCherrypick;
@@ -1090,7 +1129,7 @@
             allow_conflicts: conflicts,
           }
       );
-    },
+    }
 
     _handleMoveConfirm() {
       const el = this.$.confirmMove;
@@ -1109,15 +1148,35 @@
             message: el.message,
           }
       );
-    },
+    }
 
-    _handleRevertDialogConfirm() {
+    _handleRevertDialogConfirm(e) {
+      const revertType = e.detail.revertType;
+      const message = e.detail.message;
       const el = this.$.confirmRevertDialog;
       this.$.overlay.close();
       el.hidden = true;
-      this._fireAction('/revert', this.actions.revert, false,
-          {message: el.message});
-    },
+      switch (revertType) {
+        case REVERT_TYPES.REVERT_SINGLE_CHANGE:
+          this._fireAction('/revert', this.actions.revert, false,
+              {message});
+          break;
+        case REVERT_TYPES.REVERT_SUBMISSION:
+          this._fireAction('/revert_submission', this.actions.revert_submission,
+              false, {message});
+          break;
+        default:
+          console.error('invalid revert type');
+      }
+    }
+
+    _handleRevertSubmissionDialogConfirm() {
+      const el = this.$.confirmRevertSubmissionDialog;
+      this.$.overlay.close();
+      el.hidden = true;
+      this._fireAction('/revert_submission', this.actions.revert_submission,
+          false, {message: el.message});
+    }
 
     _handleAbandonDialogConfirm() {
       const el = this.$.confirmAbandonDialog;
@@ -1125,38 +1184,37 @@
       el.hidden = true;
       this._fireAction('/abandon', this.actions.abandon, false,
           {message: el.message});
-    },
+    }
 
     _handleCreateFollowUpChange() {
       this.$.createFollowUpChange.handleCreateChange();
       this._handleCloseCreateFollowUpChange();
-    },
+    }
 
     _handleCloseCreateFollowUpChange() {
       this.$.overlay.close();
-    },
+    }
 
     _handleDeleteConfirm() {
       this._fireAction('/', this.actions[ChangeActions.DELETE], false);
-    },
+    }
 
     _handleDeleteEditConfirm() {
       this._hideAllDialogs();
 
       this._fireAction('/edit', this.actions.deleteEdit, false);
-    },
+    }
 
     _handleSubmitConfirm() {
       if (!this._canSubmitChange()) { return; }
       this._hideAllDialogs();
       this._fireAction('/submit', this.revisionActions.submit, true);
-    },
+    }
 
     _getActionOverflowIndex(type, key) {
-      return this._overflowActions.findIndex(action => {
-        return action.type === type && action.key === key;
-      });
-    },
+      return this._overflowActions
+          .findIndex(action => action.type === type && action.key === key);
+    }
 
     _setLoadingOnButtonWithKey(type, key) {
       this._actionLoadingMessage = this._computeLoadingLabel(key);
@@ -1179,7 +1237,7 @@
         buttonEl.removeAttribute('loading');
         buttonEl.disabled = false;
       }.bind(this);
-    },
+    }
 
     /**
      * @param {string} endpoint
@@ -1193,7 +1251,7 @@
 
       this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
           action).then(this._handleResponse.bind(this, action));
-    },
+    }
 
     _showActionDialog(dialog) {
       this._hideAllDialogs();
@@ -1204,7 +1262,7 @@
           dialog.resetFocus();
         }
       });
-    },
+    }
 
     // TODO(rmistry): Redo this after
     // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
@@ -1212,7 +1270,7 @@
       const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
       if (!labels) { return Promise.resolve(); }
       return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
-    },
+    }
 
     _handleResponse(action, response) {
       if (!response) { return; }
@@ -1241,13 +1299,24 @@
           case ChangeActions.REBASE_EDIT:
             Gerrit.Nav.navigateToChange(this.change);
             break;
+          case ChangeActions.REVERT_SUBMISSION:
+            if (!obj.revert_changes || !obj.revert_changes.length) return;
+            /* If there is only 1 change then gerrit will automatically
+               redirect to that change */
+            Gerrit.Nav.navigateToSearchQuery('topic: ' +
+                obj.revert_changes[0].topic);
+            break;
           default:
             this.dispatchEvent(new CustomEvent('reload-change',
                 {detail: {action: action.__key}, bubbles: false}));
             break;
         }
       });
-    },
+    }
+
+    _handleShowRevertSubmissionChangesConfirm() {
+      this._hideAllDialogs();
+    }
 
     _handleResponseError(action, response, body) {
       if (action && action.__key === RevisionActions.CHERRYPICK) {
@@ -1264,7 +1333,7 @@
           throw Error(errText);
         }
       });
-    },
+    }
 
     /**
      * @param {string} method
@@ -1307,58 +1376,58 @@
                   return response;
                 });
           });
-    },
+    }
 
     _handleAbandonTap() {
       this._showActionDialog(this.$.confirmAbandonDialog);
-    },
+    }
 
     _handleCherrypickTap() {
       this.$.confirmCherrypick.branch = '';
       this._showActionDialog(this.$.confirmCherrypick);
-    },
+    }
 
     _handleMoveTap() {
       this.$.confirmMove.branch = '';
       this.$.confirmMove.message = '';
       this._showActionDialog(this.$.confirmMove);
-    },
+    }
 
     _handleDownloadTap() {
       this.fire('download-tap', null, {bubbles: false});
-    },
+    }
 
     _handleDeleteTap() {
       this._showActionDialog(this.$.confirmDeleteDialog);
-    },
+    }
 
     _handleDeleteEditTap() {
       this._showActionDialog(this.$.confirmDeleteEditDialog);
-    },
+    }
 
     _handleFollowUpTap() {
       this._showActionDialog(this.$.createFollowUpDialog);
-    },
+    }
 
     _handleWipTap() {
       this._fireAction('/wip', this.actions.wip, false);
-    },
+    }
 
     _handlePublishEditTap() {
       this._fireAction('/edit:publish', this.actions.publishEdit, false);
-    },
+    }
 
     _handleRebaseEditTap() {
       this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
-    },
+    }
 
     _handleHideBackgroundContent() {
       this.$.mainContent.classList.add('overlayOpen');
-    },
+    }
 
     _handleShowBackgroundContent() {
       this.$.mainContent.classList.remove('overlayOpen');
-    },
+    }
 
     /**
      * Merge sources of change actions into a single ordered array of action
@@ -1402,13 +1471,12 @@
             }
             return action;
           });
-    },
+    }
 
     _getActionPriority(action) {
       if (action.__type && action.__key) {
-        const overrideAction = this._actionPriorityOverrides.find(i => {
-          return i.type === action.__type && i.key === action.__key;
-        });
+        const overrideAction = this._actionPriorityOverrides
+            .find(i => i.type === action.__type && i.key === action.__key);
 
         if (overrideAction !== undefined) {
           return overrideAction.priority;
@@ -1424,7 +1492,7 @@
         return ActionPriority.REVISION;
       }
       return ActionPriority.DEFAULT;
-    },
+    }
 
     /**
      * Sort comparator to define the order of change actions.
@@ -1438,7 +1506,7 @@
       } else {
         return priorityDelta;
       }
-    },
+    }
 
     _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
       const hiddenActions = hiddenActionsRecord.base || [];
@@ -1446,14 +1514,14 @@
         const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
         return !(overflow || hiddenActions.includes(a.__key));
       });
-    },
+    }
 
     _filterPrimaryActions(_topLevelActions) {
       this._topLevelPrimaryActions = _topLevelActions.filter(action =>
         action.__primary);
       this._topLevelSecondaryActions = _topLevelActions.filter(action =>
         !action.__primary);
-    },
+    }
 
     _computeMenuActions(actionRecord, hiddenActionsRecord) {
       const hiddenActions = hiddenActionsRecord.base || [];
@@ -1470,7 +1538,7 @@
           tooltip: action.title,
         };
       });
-    },
+    }
 
     /**
      * Occasionally, a change created by a change action is not yet knwon to the
@@ -1504,22 +1572,24 @@
         };
         check();
       });
-    },
+    }
 
     _handleEditTap() {
       this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
-    },
+    }
 
     _handleStopEditTap() {
       this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
-    },
+    }
 
     _computeHasTooltip(title) {
       return !!title;
-    },
+    }
 
     _computeHasIcon(action) {
       return action.icon ? '' : 'hidden';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeActions.is, GrChangeActions);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index c686127..088bebd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -37,6 +37,7 @@
 </test-fixture>
 
 <script>
+  // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
   suite('gr-change-actions tests', () => {
     let element;
     let sandbox;
@@ -220,9 +221,8 @@
 
     test('delete buttons have explicit labels', done => {
       flush(() => {
-        const deleteItems = element.$.moreActions.items.filter(item => {
-          return item.id.startsWith('delete');
-        });
+        const deleteItems = element.$.moreActions.items
+            .filter(item => item.id.startsWith('delete'));
         assert.equal(deleteItems.length, 1);
         assert.notEqual(deleteItems[0].name);
         assert.equal(deleteItems[0].name, 'Delete change');
@@ -261,7 +261,7 @@
       sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sandbox.stub(element, 'fetchChangeUpdates',
-          () => { return Promise.resolve({isLatest: true}); });
+          () => Promise.resolve({isLatest: true}));
       sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
         revisions: {
@@ -284,7 +284,7 @@
       sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sandbox.stub(element, 'fetchChangeUpdates',
-          () => { return Promise.resolve({isLatest: true}); });
+          () => Promise.resolve({isLatest: true}));
       sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
         revisions: {
@@ -318,7 +318,7 @@
 
     test('submit change with plugin hook', done => {
       sandbox.stub(element, '_canSubmitChange',
-          () => { return false; });
+          () => false);
       const fireActionStub = sandbox.stub(element, '_fireAction');
       flush(() => {
         const submitButton = element.$$('gr-button[data-action-key="submit"]');
@@ -466,7 +466,9 @@
         element._handleDeleteEditTap();
         assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
         MockInteractions.tap(
-            element.$$('#confirmDeleteEditDialog').$$('gr-button[primary]'));
+            element.shadowRoot
+                .querySelector('#confirmDeleteEditDialog')
+                .$$('gr-button[primary]'));
         flushAsynchronousOperations();
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
@@ -794,12 +796,12 @@
     });
 
     suite('revert change', () => {
-      let alertStub;
       let fireActionStub;
 
       setup(() => {
         fireActionStub = sandbox.stub(element, '_fireAction');
-        alertStub = sandbox.stub(window, 'alert');
+        element.commitMessage = 'random commit message';
+        element.change.current_revision = 'abcdef';
         element.actions = {
           revert: {
             method: 'POST',
@@ -812,50 +814,195 @@
       });
 
       test('revert change with plugin hook', done => {
+        const newRevertMsg = 'Modified revert msg';
+        sandbox.stub(element.$.confirmRevertDialog, '_modifyRevertMsg',
+            () => newRevertMsg);
         element.change = {
           current_revision: 'abc1234',
         };
-        const newRevertMsg = 'Modified revert msg';
-        sandbox.stub(element, '_modifyRevertMsg',
-            () => { return newRevertMsg; });
-        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
-            () => { return 'original msg'; });
+        sandbox.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([
+              {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+            ]));
+        sandbox.stub(element.$.confirmRevertDialog,
+            '_populateRevertSubmissionMessage', () => 'original msg');
         flush(() => {
-          const revertButton =
-              element.$$('gr-button[data-action-key="revert"]');
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
           MockInteractions.tap(revertButton);
-
-          assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
-          done();
+          flush(() => {
+            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+            done();
+          });
         });
       });
 
-      test('works', () => {
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
-            () => { return 'original msg'; });
-        const revertButton = element.$$('gr-button[data-action-key="revert"]');
-        MockInteractions.tap(revertButton);
+      suite('revert change submitted together', () => {
+        setup(() => {
+          element.change = {
+            submission_id: '199',
+            current_revision: '2000',
+          };
+          sandbox.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve([
+                {change_id: '12345678901234', topic: 'T', subject: 'random'},
+                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+              ]));
+        });
 
-        element.$.confirmRevertDialog.message = 'foo message';
-        element._handleRevertDialogConfirm();
-        assert.notOk(alertStub.called);
+        test('confirm revert dialog shows both options', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const revertSingleChangeLabel = confirmRevertDialog
+                .shadowRoot.querySelector('.revertSingleChange');
+            const revertSubmissionLabel = confirmRevertDialog.
+                shadowRoot.querySelector('.revertSubmission');
+            assert(revertSingleChangeLabel.innerText.trim() ===
+                'Revert single change');
+            assert(revertSubmissionLabel.innerText.trim() ===
+                'Revert entire submission (2 Changes)');
+            let expectedMsg = 'Revert submission 199' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890:random' + '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            assert.equal(confirmRevertDialog._message, expectedMsg);
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
+               + 'commit 2000.\n\nReason'
+               + ' for revert: <INSERT REASONING HERE>\n';
+              assert.equal(confirmRevertDialog._message, expectedMsg);
+              done();
+            });
+          });
+        });
 
-        const action = {
-          __key: 'revert',
-          __type: 'change',
-          __primary: false,
-          enabled: true,
-          label: 'Revert',
-          method: 'POST',
-          title: 'Revert the change',
-        };
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/revert', action, false, {
-            message: 'foo message',
-          }]);
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sandbox.stub(confirmRevertDialog, 'fire');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('message modification is retained on switching', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            const revertSubmissionMsg = 'Revert submission 199' + '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+            'Reverted Changes:' + '\n' +
+            '1234567890:random' + '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+            const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts '
+              + 'commit 2000.\n\nReason'
+              + ' for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+            const newRevertMsg = revertSubmissionMsg + 'random';
+            const newSingleChangeMsg = singleChangeMsg + 'random';
+            confirmRevertDialog._message = newRevertMsg;
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              assert.equal(confirmRevertDialog._message, singleChangeMsg);
+              confirmRevertDialog._message = newSingleChangeMsg;
+              MockInteractions.tap(radioInputs[1]);
+              flush(() => {
+                assert.equal(confirmRevertDialog._message, newRevertMsg);
+                MockInteractions.tap(radioInputs[0]);
+                flush(() => {
+                  assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
+                  done();
+                });
+              });
+            });
+          });
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            submission_id: '199',
+            current_revision: '2000',
+          };
+          sandbox.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve([
+                {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              ]));
+        });
+
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sandbox.stub(confirmRevertDialog, 'fire');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('confirm revert dialog shows no radio button', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            assert.equal(radioInputs.length, 0);
+            const msg = 'Revert "random commit message"\n\n'
+              + 'This reverts commit 2000.\n\nReason '
+              + 'for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, msg);
+            const editedMsg = msg + 'hello';
+            confirmRevertDialog._message += 'hello';
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+              assert.equal(fireActionStub.getCall(0).args[3].message,
+                  editedMsg);
+              done();
+            });
+          });
+        });
       });
     });
 
@@ -980,9 +1127,12 @@
 
       test('shows confirm dialog', () => {
         element._handleDeleteTap();
-        assert.isFalse(element.$$('#confirmDeleteDialog').hidden);
+        assert.isFalse(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
         MockInteractions.tap(
-            element.$$('#confirmDeleteDialog').$$('gr-button[primary]'));
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .$$('gr-button[primary]'));
         flushAsynchronousOperations();
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
@@ -990,9 +1140,12 @@
       test('hides delete confirm on cancel', () => {
         element._handleDeleteTap();
         MockInteractions.tap(
-            element.$$('#confirmDeleteDialog').$$('gr-button:not([primary])'));
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .$$('gr-button:not([primary])'));
         flushAsynchronousOperations();
-        assert.isTrue(element.$$('#confirmDeleteDialog').hidden);
+        assert.isTrue(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
         assert.isFalse(fireActionStub.called);
       });
     });
@@ -1060,7 +1213,6 @@
         element.reload().then(() => { flush(done); });
       });
 
-
       test('unignore button is not outside of the overflow menu', () => {
         assert.isNotOk(element.$$('[data-action-key="unignore"]'));
       });
@@ -1140,7 +1292,6 @@
         element.reload().then(() => { flush(done); });
       });
 
-
       test('unreviewed button not outside of the overflow menu', () => {
         assert.isNotOk(element.$$('[data-action-key="unreviewed"]'));
       });
@@ -1392,15 +1543,13 @@
           sandbox.stub(element, 'async', fn => fn());
         });
 
-        const makeGetChange = numTries => {
-          return () => {
-            if (numTries === 1) {
-              return Promise.resolve({_number: 123});
-            } else {
-              numTries--;
-              return Promise.resolve(undefined);
-            }
-          };
+        const makeGetChange = numTries => () => {
+          if (numTries === 1) {
+            return Promise.resolve({_number: 123});
+          } else {
+            numTries--;
+            return Promise.resolve(undefined);
+          }
         };
 
         test('succeed', () => {
@@ -1424,6 +1573,7 @@
       let payload;
       let onShowError;
       let onShowAlert;
+      let getResponseObjectStub;
 
       setup(() => {
         cleanup = sinon.stub();
@@ -1439,31 +1589,120 @@
 
       suite('happy path', () => {
         let sendStub;
-
         setup(() => {
           sandbox.stub(element, 'fetchChangeUpdates')
               .returns(Promise.resolve({isLatest: true}));
           sendStub = sandbox.stub(element.$.restAPI, 'executeChangeAction')
               .returns(Promise.resolve({}));
+          getResponseObjectStub = sandbox.stub(element.$.restAPI,
+              'getResponseObject');
+          sandbox.stub(Gerrit.Nav,
+              'navigateToChange').returns(Promise.resolve(true));
         });
 
-        test('change action', () => {
-          return element._send('DELETE', payload, '/endpoint', false, cleanup)
+        test('change action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', false, cleanup)
               .then(() => {
                 assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
                     null, payload));
+                done();
               });
         });
 
-        test('revision action', () => {
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+        suite('show revert submission dialog', () => {
+          setup(() => {
+            element.change.submission_id = '199';
+            element.change.current_revision = '2000';
+            sandbox.stub(element.$.restAPI, 'getChanges')
+                .returns(Promise.resolve([
+                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
+                  {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+                ]));
+          });
+
+          test('revert submission shows submissionId', done => {
+            const expectedMsg = 'Revert submission 199' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890: random' + '\n' +
+              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            const modifiedMsg = expectedMsg + 'abcd';
+            sandbox.stub(element.$.confirmRevertSubmissionDialog,
+                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
+            element.showRevertSubmissionDialog();
+            flush(() => {
+              const msg = element.$.confirmRevertSubmissionDialog.message;
+              assert.equal(msg, modifiedMsg);
+              done();
+            });
+          });
+        });
+
+        suite('single changes revert', () => {
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345},
+                ]}));
+            showActionDialogStub = sandbox.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sandbox.stub(Gerrit.Nav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission single change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).
+                  then(() => {
+                    assert.isTrue(navigateToSearchQueryStub.called);
+                    done();
+                  });
+            });
+          });
+        });
+
+        suite('multiple changes revert', () => {
+          let showActionDialogStub;
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ]}));
+            showActionDialogStub = sandbox.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sandbox.stub(Gerrit.Nav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission multiple change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).then(
+                  () => {
+                    assert.isFalse(showActionDialogStub.called);
+                    assert.isTrue(navigateToSearchQueryStub.calledWith(
+                        'topic: T'));
+                    done();
+                  });
+            });
+          });
+        });
+
+        test('revision action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', true, cleanup)
               .then(() => {
                 assert.isFalse(onShowError.called);
                 assert.isTrue(cleanup.calledOnce);
                 assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
                     12, payload));
+                done();
               });
         });
       });
@@ -1541,7 +1780,6 @@
       element.changeNum = '42';
       element.latestPatchNum = '2';
 
-
       sandbox.stub(element.$.confirmCherrypick.$.restAPI,
           'getRepoBranches').returns(Promise.resolve([]));
       sandbox.stub(element.$.confirmMove.$.restAPI,
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
index b8bea9ca..8d85dce 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 6a92d96..9073342 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -43,7 +43,9 @@
 
 <dom-module id="gr-change-metadata">
   <template>
-    <style include="gr-change-metadata-shared-styles"></style>
+    <style include="gr-change-metadata-shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       :host {
         display: table;
@@ -175,9 +177,10 @@
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
-              max-count="1"
               id="assigneeValue"
               placeholder="Set assignee..."
+              max-count="1"
+              skip-suggest-on-empty
               accounts="{{_assignee}}"
               readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
               suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]">
@@ -267,6 +270,19 @@
           </template>
         </span>
       </section>
+      <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
+        <section>
+          <span class="title">Cherry pick of</span>
+          <span class="value">
+            <a href$="[[_computeCherryPickOfURL(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]">
+              <gr-limited-text
+                  text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]"
+                  limit="40">
+              </gr-limited-text>
+            </a>
+          </span>
+        </section>
+      </template>
       <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
         <span class="title">Strategy</span>
         <span class="value">[[_computeStrategy(change)]]</span>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index b6d0faa..3237d72 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -48,105 +48,112 @@
     TRUSTED: 'TRUSTED',
   };
 
-  Polymer({
-    is: 'gr-change-metadata',
-
+  /**
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeMetadata extends Polymer.mixinBehaviors( [
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-metadata'; }
     /**
      * Fired when the change topic is changed.
      *
      * @event topic-changed
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      change: Object,
-      labels: {
-        type: Object,
-        notify: true,
-      },
-      account: Object,
-      /** @type {?} */
-      revision: Object,
-      commitInfo: Object,
-      _mutable: {
-        type: Boolean,
-        computed: '_computeIsMutable(account)',
-      },
-      /** @type {?} */
-      serverConfig: Object,
-      parentIsCurrent: Boolean,
-      _notCurrentMessage: {
-        type: String,
-        value: NOT_CURRENT_MESSAGE,
-        readOnly: true,
-      },
-      _topicReadOnly: {
-        type: Boolean,
-        computed: '_computeTopicReadOnly(_mutable, change)',
-      },
-      _hashtagReadOnly: {
-        type: Boolean,
-        computed: '_computeHashtagReadOnly(_mutable, change)',
-      },
-      /**
-       * @type {Gerrit.PushCertificateValidation}
-       */
-      _pushCertificateValidation: {
-        type: Object,
-        computed: '_computePushCertificateValidation(serverConfig, change)',
-      },
-      _showRequirements: {
-        type: Boolean,
-        computed: '_computeShowRequirements(change)',
-      },
-
-      _assignee: Array,
-      _isWip: {
-        type: Boolean,
-        computed: '_computeIsWip(change)',
-      },
-      _newHashtag: String,
-
-      _settingTopic: {
-        type: Boolean,
-        value: false,
-      },
-
-      _currentParents: {
-        type: Array,
-        computed: '_computeParents(change)',
-      },
-
-      /** @type {?} */
-      _CHANGE_ROLE: {
-        type: Object,
-        readOnly: true,
-        value: {
-          OWNER: 'owner',
-          UPLOADER: 'uploader',
-          AUTHOR: 'author',
-          COMMITTER: 'committer',
+        change: Object,
+        labels: {
+          type: Object,
+          notify: true,
         },
-      },
-    },
+        account: Object,
+        /** @type {?} */
+        revision: Object,
+        commitInfo: Object,
+        _mutable: {
+          type: Boolean,
+          computed: '_computeIsMutable(account)',
+        },
+        /** @type {?} */
+        serverConfig: Object,
+        parentIsCurrent: Boolean,
+        _notCurrentMessage: {
+          type: String,
+          value: NOT_CURRENT_MESSAGE,
+          readOnly: true,
+        },
+        _topicReadOnly: {
+          type: Boolean,
+          computed: '_computeTopicReadOnly(_mutable, change)',
+        },
+        _hashtagReadOnly: {
+          type: Boolean,
+          computed: '_computeHashtagReadOnly(_mutable, change)',
+        },
+        /**
+         * @type {Gerrit.PushCertificateValidation}
+         */
+        _pushCertificateValidation: {
+          type: Object,
+          computed: '_computePushCertificateValidation(serverConfig, change)',
+        },
+        _showRequirements: {
+          type: Boolean,
+          computed: '_computeShowRequirements(change)',
+        },
 
-    behaviors: [
-      Gerrit.RESTClientBehavior,
-    ],
+        _assignee: Array,
+        _isWip: {
+          type: Boolean,
+          computed: '_computeIsWip(change)',
+        },
+        _newHashtag: String,
 
-    observers: [
-      '_changeChanged(change)',
-      '_labelsChanged(change.labels)',
-      '_assigneeChanged(_assignee.*)',
-    ],
+        _settingTopic: {
+          type: Boolean,
+          value: false,
+        },
+
+        _currentParents: {
+          type: Array,
+          computed: '_computeParents(change)',
+        },
+
+        /** @type {?} */
+        _CHANGE_ROLE: {
+          type: Object,
+          readOnly: true,
+          value: {
+            OWNER: 'owner',
+            UPLOADER: 'uploader',
+            AUTHOR: 'author',
+            COMMITTER: 'committer',
+          },
+        },
+      };
+    }
+
+    static get observers() {
+      return [
+        '_changeChanged(change)',
+        '_labelsChanged(change.labels)',
+        '_assigneeChanged(_assignee.*)',
+      ];
+    }
 
     _labelsChanged(labels) {
       this.labels = Object.assign({}, labels) || null;
-    },
+    }
 
     _changeChanged(change) {
       this._assignee = change.assignee ? [change.assignee] : [];
-    },
+    }
 
     _assigneeChanged(assigneeRecord) {
       if (!this.change) { return; }
@@ -162,11 +169,11 @@
         this.set(['change', 'assignee'], undefined);
         this.$.restAPI.deleteAssignee(this.change._number);
       }
-    },
+    }
 
     _computeHideStrategy(change) {
       return !this.changeIsOpen(change);
-    },
+    }
 
     /**
      * @param {Object} commitInfo
@@ -184,15 +191,15 @@
             config: serverConfig,
           });
       return weblinks.length ? weblinks : null;
-    },
+    }
 
     _computeStrategy(change) {
       return SubmitTypeLabel[change.submit_type];
-    },
+    }
 
     _computeLabelNames(labels) {
       return Object.keys(labels).sort();
-    },
+    }
 
     _handleTopicChanged(e, topic) {
       const lastTopic = this.change.topic;
@@ -207,19 +214,26 @@
                   'topic-changed', {bubbles: true, composed: true}));
             }
           });
-    },
+    }
 
     _showAddTopic(changeRecord, settingTopic) {
       const hasTopic = !!changeRecord &&
           !!changeRecord.base && !!changeRecord.base.topic;
       return !hasTopic && !settingTopic;
-    },
+    }
 
     _showTopicChip(changeRecord, settingTopic) {
       const hasTopic = !!changeRecord &&
           !!changeRecord.base && !!changeRecord.base.topic;
       return hasTopic && !settingTopic;
-    },
+    }
+
+    _showCherryPickOf(changeRecord) {
+      const hasCherryPickOf = !!changeRecord &&
+          !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
+          !!changeRecord.base.cherry_pick_of_patch_set;
+      return hasCherryPickOf;
+    }
 
     _handleHashtagChanged(e) {
       const lastHashtag = this.change.hashtag;
@@ -235,7 +249,7 @@
                 bubbles: true, composed: true}));
         }
       });
-    },
+    }
 
     _computeTopicReadOnly(mutable, change) {
       return !mutable ||
@@ -243,7 +257,7 @@
           !change.actions ||
           !change.actions.topic ||
           !change.actions.topic.enabled;
-    },
+    }
 
     _computeHashtagReadOnly(mutable, change) {
       return !mutable ||
@@ -251,7 +265,7 @@
           !change.actions ||
           !change.actions.hashtags ||
           !change.actions.hashtags.enabled;
-    },
+    }
 
     _computeAssigneeReadOnly(mutable, change) {
       return !mutable ||
@@ -259,17 +273,17 @@
           !change.actions ||
           !change.actions.assignee ||
           !change.actions.assignee.enabled;
-    },
+    }
 
     _computeTopicPlaceholder(_topicReadOnly) {
       // Action items in Material Design are uppercase -- placeholder label text
       // is sentence case.
       return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
-    },
+    }
 
     _computeHashtagPlaceholder(_hashtagReadOnly) {
       return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
-    },
+    }
 
     _computeShowRequirements(change) {
       if (change.status !== this.ChangeStatus.NEW) {
@@ -282,7 +296,7 @@
       const hasLabels = !!change.labels &&
           Object.keys(change.labels).length > 0;
       return hasRequirements || hasLabels || !!change.work_in_progress;
-    },
+    }
 
     /**
      * @return {?Gerrit.PushCertificateValidation} object representing data for
@@ -327,7 +341,7 @@
         default:
           throw new Error(`unknown certificate status: ${key.status}`);
       }
-    },
+    }
 
     _problems(msg, key) {
       if (!key || !key.problems || key.problems.length === 0) {
@@ -335,40 +349,47 @@
       }
 
       return [msg + ':'].concat(key.problems).join('\n');
-    },
+    }
 
     _computeProjectURL(project) {
       return Gerrit.Nav.getUrlForProjectChanges(project);
-    },
+    }
 
     _computeBranchURL(project, branch) {
       if (!this.change || !this.change.status) return '';
       return Gerrit.Nav.getUrlForBranch(branch, project,
           this.change.status == this.ChangeStatus.NEW ? 'open' :
             this.change.status.toLowerCase());
-    },
+    }
+
+    _computeCherryPickOfURL(change, patchset, project) {
+      return Gerrit.Nav.getUrlForChangeById(change, project, patchset);
+    }
 
     _computeTopicURL(topic) {
       return Gerrit.Nav.getUrlForTopic(topic);
-    },
+    }
 
     _computeHashtagURL(hashtag) {
       return Gerrit.Nav.getUrlForHashtag(hashtag);
-    },
+    }
 
     _handleTopicRemoved(e) {
       const target = Polymer.dom(e).rootTarget;
       target.disabled = true;
-      this.$.restAPI.setChangeTopic(this.change._number, null).then(() => {
-        target.disabled = false;
-        this.set(['change', 'topic'], '');
-        this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true, composed: true}));
-      }).catch(err => {
-        target.disabled = false;
-        return;
-      });
-    },
+      this.$.restAPI.setChangeTopic(this.change._number, null)
+          .then(() => {
+            target.disabled = false;
+            this.set(['change', 'topic'], '');
+            this.dispatchEvent(
+                new CustomEvent('topic-changed',
+                    {bubbles: true, composed: true}));
+          })
+          .catch(err => {
+            target.disabled = false;
+            return;
+          });
+    }
 
     _handleHashtagRemoved(e) {
       e.preventDefault();
@@ -379,19 +400,20 @@
           .then(newHashtag => {
             target.disabled = false;
             this.set(['change', 'hashtags'], newHashtag);
-          }).catch(err => {
+          })
+          .catch(err => {
             target.disabled = false;
             return;
           });
-    },
+    }
 
     _computeIsWip(change) {
       return !!change.work_in_progress;
-    },
+    }
 
     _computeShowRoleClass(change, role) {
       return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
-    },
+    }
 
     /**
      * Get the user with the specified role on the change. Returns null if the
@@ -429,7 +451,7 @@
       }
 
       return null;
-    },
+    }
 
     _computeParents(change) {
       if (!change || !change.current_revision ||
@@ -438,11 +460,11 @@
         return undefined;
       }
       return change.revisions[change.current_revision].commit.parents;
-    },
+    }
 
     _computeParentsLabel(parents) {
       return parents && parents.length > 1 ? 'Parents' : 'Parent';
-    },
+    }
 
     _computeParentListClass(parents, parentIsCurrent) {
       // Undefined check for polymer 2
@@ -455,24 +477,26 @@
         parents && parents.length > 1 ? 'merge' : 'nonMerge',
         parentIsCurrent ? 'current' : 'notCurrent',
       ].join(' ');
-    },
+    }
 
     _computeIsMutable(account) {
       return !!Object.keys(account).length;
-    },
+    }
 
     editTopic() {
       if (this._topicReadOnly || this.change.topic) { return; }
       // Cannot use `this.$.ID` syntax because the element exists inside of a
       // dom-if.
       this.$$('.topicEditableLabel').open();
-    },
+    }
 
     _getReviewerSuggestionsProvider(change) {
       const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
           change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
       provider.init();
       return provider;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeMetadata.is, GrChangeMetadata);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 6f06fc8..6acf4fe 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-metadata</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -464,6 +464,22 @@
       assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
     });
 
+    test('_showCherryPickOf', () => {
+      assert.isFalse(element._showCherryPickOf(null));
+      assert.isFalse(element._showCherryPickOf({
+        base: {
+          cherry_pick_of_change: null,
+          cherry_pick_of_patch_set: null,
+        },
+      }));
+      assert.isTrue(element._showCherryPickOf({
+        base: {
+          cherry_pick_of_change: 123,
+          cherry_pick_of_patch_set: 1,
+        },
+      }));
+    });
+
     suite('Topic removal', () => {
       let change;
       setup(() => {
@@ -720,8 +736,8 @@
         Gerrit.install(
             p => {
               plugin = p;
-              plugin.hook('change-metadata-item').getLastAttached().then(
-                  el => hookEl = el);
+              plugin.hook('change-metadata-item').getLastAttached()
+                  .then(el => hookEl = el);
             },
             '0.1',
             'http://some/plugins/url.html');
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index dfdcd59..a413c6f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -17,47 +17,55 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-change-requirements',
+  /**
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeRequirements extends Polymer.mixinBehaviors( [
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-requirements'; }
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      change: Object,
-      account: Object,
-      mutable: Boolean,
-      _requirements: {
-        type: Array,
-        computed: '_computeRequirements(change)',
-      },
-      _requiredLabels: {
-        type: Array,
-        value: () => [],
-      },
-      _optionalLabels: {
-        type: Array,
-        value: () => [],
-      },
-      _showWip: {
-        type: Boolean,
-        computed: '_computeShowWip(change)',
-      },
-      _showOptionalLabels: {
-        type: Boolean,
-        value: true,
-      },
-    },
+        change: Object,
+        account: Object,
+        mutable: Boolean,
+        _requirements: {
+          type: Array,
+          computed: '_computeRequirements(change)',
+        },
+        _requiredLabels: {
+          type: Array,
+          value: () => [],
+        },
+        _optionalLabels: {
+          type: Array,
+          value: () => [],
+        },
+        _showWip: {
+          type: Boolean,
+          computed: '_computeShowWip(change)',
+        },
+        _showOptionalLabels: {
+          type: Boolean,
+          value: true,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
-      '_computeLabels(change.labels.*)',
-    ],
+    static get observers() {
+      return [
+        '_computeLabels(change.labels.*)',
+      ];
+    }
 
     _computeShowWip(change) {
       return change.work_in_progress;
-    },
+    }
 
     _computeRequirements(change) {
       const _requirements = [];
@@ -78,15 +86,15 @@
       }
 
       return _requirements;
-    },
+    }
 
     _computeRequirementClass(requirementStatus) {
       return requirementStatus ? 'approved' : '';
-    },
+    }
 
     _computeRequirementIcon(requirementStatus) {
       return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
-    },
+    }
 
     _computeLabels(labelsRecord) {
       const labels = labelsRecord.base;
@@ -103,7 +111,7 @@
 
         this.push(path, {label, icon, style, labelInfo});
       }
-    },
+    }
 
     /**
      * @param {Object} labelInfo
@@ -114,7 +122,7 @@
       if (labelInfo.approved) { return 'gr-icons:check'; }
       if (labelInfo.rejected) { return 'gr-icons:close'; }
       return 'gr-icons:hourglass';
-    },
+    }
 
     /**
      * @param {Object} labelInfo
@@ -123,28 +131,30 @@
       if (labelInfo.approved) { return 'approved'; }
       if (labelInfo.rejected) { return 'rejected'; }
       return '';
-    },
+    }
 
     _computeShowOptional(optionalFieldsRecord) {
       return optionalFieldsRecord.base.length ? '' : 'hidden';
-    },
+    }
 
     _computeLabelValue(value) {
       return (value > 0 ? '+' : '') + value;
-    },
+    }
 
     _computeShowHideIcon(showOptionalLabels) {
       return showOptionalLabels ?
         'gr-icons:expand-less' :
         'gr-icons:expand-more';
-    },
+    }
 
     _computeSectionClass(show) {
       return show ? '' : 'hidden';
-    },
+    }
 
     _handleShowHide(e) {
       this._showOptionalLabels = !this._showOptionalLabels;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeRequirements.is, GrChangeRequirements);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index 2ceac39..242fe2c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-requirements</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index b3280a6..623e8d1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -51,6 +51,7 @@
 <link rel="import" href="../gr-included-in-dialog/gr-included-in-dialog.html">
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
+<link rel="import" href="../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
 <link rel="import" href="../gr-thread-list/gr-thread-list.html">
 <link rel="import" href="../gr-upload-help-dialog/gr-upload-help-dialog.html">
@@ -92,6 +93,9 @@
         flex: 1;
         font-size: var(--font-size-h3);
       }
+      .changeNumberColon {
+        color: transparent;
+      }
       .headerTitle .headerSubject {
         font-weight: var(--font-weight-bold);
       }
@@ -100,6 +104,7 @@
       }
       gr-change-star {
         margin-right: var(--spacing-xs);
+        margin-left: var(--spacing-l);
       }
       gr-reply-dialog {
         width: 60em;
@@ -130,15 +135,15 @@
         line-height: var(--line-height-mono);
         margin-right: var(--spacing-l);
         margin-bottom: var(--spacing-l);
-        /* Account for border and padding */
-        max-width: calc(72ch + 2px + 2*var(--spacing-m));
+        /* Account for border and padding and rounding errors. */
+        max-width: calc(72ch + 2px + 2*var(--spacing-m) + 0.4px);
       }
       .commitMessage gr-linked-text {
         word-break: break-word;
       }
       #commitMessageEditor {
-        /* Account for border and padding */
-        min-width: calc(72ch + 2px + 2*var(--spacing-m));
+        /* Account for border and padding and rounding errors. */
+        min-width: calc(72ch + 2px + 2*var(--spacing-m) + 0.4px);
       }
       .editCommitMessage {
         margin-top: var(--spacing-l);
@@ -185,12 +190,6 @@
         height: 0;
         margin-bottom: var(--spacing-l);
       }
-      #commitMessage.collapsed {
-        max-height: 36em;
-        overflow: hidden;
-      }
-      #relatedChanges {
-      }
       #relatedChanges.collapsed {
         margin-bottom: var(--spacing-l);
         max-height: var(--relation-chain-max-height, 2em);
@@ -363,11 +362,6 @@
         hidden$="{{_loading}}">
       <div class$="[[_computeHeaderClass(_editMode)]]">
         <div class="headerTitle">
-          <gr-change-star
-              id="changeStar"
-              change="{{_change}}"
-              on-toggle-star="_handleToggleStar"
-              hidden$="[[!_loggedIn]]"></gr-change-star>
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
               <gr-change-status
@@ -386,11 +380,20 @@
                   server-config="[[_serverConfig]]"></gr-commit-info>
             </template>
           </div>
-          <span class="separator"></span>
+          <gr-change-star
+              id="changeStar"
+              change="{{_change}}"
+              on-toggle-star="_handleToggleStar"
+              hidden$="[[!_loggedIn]]"></gr-change-star>
+
           <a aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
               href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a>
-          <pre>: </pre>
+          <span class="changeNumberColon">:&nbsp;</span>
           <span class="headerSubject">[[_change.subject]]</span>
+          <gr-copy-clipboard
+            hide-input
+            text="[[_computeCopyTextForTitle(_change)]]">
+          </gr-copy-clipboard>
         </div><!-- end headerTitle -->
         <div class="commitActions" hidden$="[[!_loggedIn]]">
           <gr-change-actions
@@ -404,8 +407,6 @@
               change-status="[[_change.status]]"
               commit-num="[[_commitInfo.commit]]"
               latest-patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-              reply-disabled="[[_replyDisabled]]"
-              reply-button-label="[[_replyButtonLabel]]"
               commit-message="[[_latestCommitMessage]]"
               edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange.*)]]"
               edit-mode="[[_editMode]]"
@@ -444,12 +445,13 @@
               </div>
               <div
                   id="commitMessage"
-                  class$="commitMessage [[_computeCommitClass(_commitCollapsed, _latestCommitMessage)]]">
+                  class="commitMessage">
                 <gr-editable-content id="commitMessageEditor"
                     editing="[[_editingCommitMessage]]"
                     content="{{_latestCommitMessage}}"
                     storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
-                    remove-zero-width-space>
+                    remove-zero-width-space
+                    collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]">
                   <gr-linked-text pre
                       content="[[_latestCommitMessage]]"
                       config="[[_projectConfig.commentlinks]]"
@@ -472,7 +474,7 @@
               <div
                   id="commitCollapseToggle"
                   class="collapseToggleContainer"
-                  hidden$="[[_computeCommitToggleHidden(_latestCommitMessage)]]">
+                  hidden$="[[!_commitCollapsible]]">
                 <gr-button
                     link
                     id="commitCollapseToggleButton"
@@ -611,7 +613,8 @@
             <span>Comment Threads</span></gr-tooltip-content>
         </paper-tab>
       </paper-tabs>
-      <template is="dom-if" if="[[_showMessagesView]]">
+      <template is="dom-if" if="[[_isSelectedView(_currentView,
+        _commentTabs.CHANGE_LOG)]]">
         <gr-messages-list
             class="hideOnMobileOverlay"
             change-num="[[_changeNum]]"
@@ -624,7 +627,8 @@
             on-message-anchor-tap="_handleMessageAnchorTap"
             on-reply="_handleMessageReply"></gr-messages-list>
       </template>
-      <template is="dom-if" if="[[!_showMessagesView]]">
+      <template is="dom-if" if="[[_isSelectedView(_currentView,
+        _commentTabs.COMMENT_THREADS)]]">
         <gr-thread-list
             threads="[[_commentThreads]]"
             change="[[_change]]"
@@ -633,6 +637,11 @@
             on-thread-list-modified="_handleReloadDiffComments"></gr-thread-list>
       </template>
     </div>
+    <gr-apply-fix-dialog
+      id="applyFixDialog"
+      prefs="[[_diffPrefs]]"
+      change="[[_change]]"
+      change-num="[[_changeNum]]"></gr-apply-fix-dialog>
     <gr-overlay id="downloadOverlay" with-backdrop>
       <gr-download-dialog
           id="downloadDialog"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 7ddd51c..6a04445 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -59,13 +59,31 @@
     UNIFIED: 'UNIFIED_DIFF',
   };
 
+  const CommentTabs = {
+    CHANGE_LOG: 0,
+    COMMENT_THREADS: 1,
+  };
+
   const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
   const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
   const SEND_REPLY_TIMING_LABEL = 'SendReply';
 
-  Polymer({
-    is: 'gr-change-view',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeView extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-view'; }
     /**
      * Fired when the title of the page should change.
      *
@@ -84,230 +102,226 @@
      * @event show-auth-required
      */
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /** @type {?} */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_viewStateChanged',
-      },
-      backPage: String,
-      hasParent: Boolean,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      _commentThreads: Array,
-      /** @type {?} */
-      _serverConfig: {
-        type: Object,
-        observer: '_startUpdateCheckTimer',
-      },
-      _diffPrefs: Object,
-      _numFilesShown: {
-        type: Number,
-        value: DEFAULT_NUM_FILES_SHOWN,
-        observer: '_numFilesShownChanged',
-      },
-      _account: {
-        type: Object,
-        value: {},
-      },
-      _prefs: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _canStartReview: {
-        type: Boolean,
-        computed: '_computeCanStartReview(_change)',
-      },
-      _comments: Object,
-      /** @type {?} */
-      _change: {
-        type: Object,
-        observer: '_changeChanged',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      /** @type {?} */
-      _commitInfo: Object,
-      _currentRevision: {
-        type: Object,
-        computed: '_computeCurrentRevision(_change.current_revision, ' +
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+        /** @type {?} */
+        viewState: {
+          type: Object,
+          notify: true,
+          value() { return {}; },
+          observer: '_viewStateChanged',
+        },
+        backPage: String,
+        hasParent: Boolean,
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
+        },
+        disableEdit: {
+          type: Boolean,
+          value: false,
+        },
+        disableDiffPrefs: {
+          type: Boolean,
+          value: false,
+        },
+        _diffPrefsDisabled: {
+          type: Boolean,
+          computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+        },
+        _commentThreads: Array,
+        /** @type {?} */
+        _serverConfig: {
+          type: Object,
+          observer: '_startUpdateCheckTimer',
+        },
+        _diffPrefs: Object,
+        _numFilesShown: {
+          type: Number,
+          value: DEFAULT_NUM_FILES_SHOWN,
+          observer: '_numFilesShownChanged',
+        },
+        _account: {
+          type: Object,
+          value: {},
+        },
+        _prefs: Object,
+        /** @type {?} */
+        _changeComments: Object,
+        _canStartReview: {
+          type: Boolean,
+          computed: '_computeCanStartReview(_change)',
+        },
+        _comments: Object,
+        /** @type {?} */
+        _change: {
+          type: Object,
+          observer: '_changeChanged',
+        },
+        _revisionInfo: {
+          type: Object,
+          computed: '_getRevisionInfo(_change)',
+        },
+        /** @type {?} */
+        _commitInfo: Object,
+        _currentRevision: {
+          type: Object,
+          computed: '_computeCurrentRevision(_change.current_revision, ' +
             '_change.revisions)',
-      },
-      _files: Object,
-      _changeNum: String,
-      _diffDrafts: {
-        type: Object,
-        value() { return {}; },
-      },
-      _editingCommitMessage: {
-        type: Boolean,
-        value: false,
-      },
-      _hideEditCommitMessage: {
-        type: Boolean,
-        computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change, _editMode)',
-      },
-      _diffAgainst: String,
-      /** @type {?string} */
-      _latestCommitMessage: {
-        type: String,
-        value: '',
-      },
-      _lineHeight: Number,
-      _changeIdCommitMessageError: {
-        type: String,
-        computed:
+        },
+        _files: Object,
+        _changeNum: String,
+        _diffDrafts: {
+          type: Object,
+          value() { return {}; },
+        },
+        _editingCommitMessage: {
+          type: Boolean,
+          value: false,
+        },
+        _hideEditCommitMessage: {
+          type: Boolean,
+          computed: '_computeHideEditCommitMessage(_loggedIn, ' +
+              '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+              '_commitCollapsible)',
+        },
+        _diffAgainst: String,
+        /** @type {?string} */
+        _latestCommitMessage: {
+          type: String,
+          value: '',
+        },
+        _commentTabs: {
+          type: Object,
+          value: CommentTabs,
+        },
+        _lineHeight: Number,
+        _changeIdCommitMessageError: {
+          type: String,
+          computed:
           '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-      },
-      /** @type {?} */
-      _patchRange: {
-        type: Object,
-      },
-      _filesExpanded: String,
-      _basePatchNum: String,
-      _selectedRevision: Object,
-      _currentRevisionActions: Object,
-      _allPatchSets: {
-        type: Array,
-        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: Boolean,
-      /** @type {?} */
-      _projectConfig: Object,
-      _rebaseOnCurrent: Boolean,
-      _replyButtonLabel: {
-        type: String,
-        value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
-      },
-      _selectedPatchSet: String,
-      _shownFileCount: Number,
-      _initialLoadComplete: {
-        type: Boolean,
-        value: false,
-      },
-      _replyDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeReplyDisabled(_serverConfig)',
-      },
-      _changeStatus: {
-        type: String,
-        computed: 'changeStatusString(_change)',
-      },
-      _changeStatuses: {
-        type: String,
-        computed:
+        },
+        /** @type {?} */
+        _patchRange: {
+          type: Object,
+        },
+        _filesExpanded: String,
+        _basePatchNum: String,
+        _selectedRevision: Object,
+        _currentRevisionActions: Object,
+        _allPatchSets: {
+          type: Array,
+          computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+        },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        _loading: Boolean,
+        /** @type {?} */
+        _projectConfig: Object,
+        _rebaseOnCurrent: Boolean,
+        _replyButtonLabel: {
+          type: String,
+          value: 'Reply',
+          computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+        },
+        _selectedPatchSet: String,
+        _shownFileCount: Number,
+        _initialLoadComplete: {
+          type: Boolean,
+          value: false,
+        },
+        _replyDisabled: {
+          type: Boolean,
+          value: true,
+          computed: '_computeReplyDisabled(_serverConfig)',
+        },
+        _changeStatus: {
+          type: String,
+          computed: 'changeStatusString(_change)',
+        },
+        _changeStatuses: {
+          type: String,
+          computed:
           '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-      },
-      _commitCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      _relatedChangesCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?number} */
-      _updateCheckTimerHandle: Number,
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*, params.*)',
-      },
-      _showRelatedToggle: {
-        type: Boolean,
-        value: false,
-        observer: '_updateToggleContainerClass',
-      },
-      _parentIsCurrent: Boolean,
-      _submitEnabled: {
-        type: Boolean,
-        computed: '_isSubmitEnabled(_currentRevisionActions)',
-      },
+        },
+        /** If false, then the "Show more" button was used to expand. */
+        _commitCollapsed: {
+          type: Boolean,
+          value: true,
+        },
+        /** Is the "Show more/less" button visible? */
+        _commitCollapsible: {
+          type: Boolean,
+          computed: '_computeCommitCollapsible(_latestCommitMessage)',
+        },
+        _relatedChangesCollapsed: {
+          type: Boolean,
+          value: true,
+        },
+        /** @type {?number} */
+        _updateCheckTimerHandle: Number,
+        _editMode: {
+          type: Boolean,
+          computed: '_computeEditMode(_patchRange.*, params.*)',
+        },
+        _showRelatedToggle: {
+          type: Boolean,
+          value: false,
+          observer: '_updateToggleContainerClass',
+        },
+        _parentIsCurrent: Boolean,
+        _submitEnabled: {
+          type: Boolean,
+          computed: '_isSubmitEnabled(_currentRevisionActions)',
+        },
 
-      /** @type {?} */
-      _mergeable: {
-        type: Boolean,
-        value: undefined,
-      },
-      _showMessagesView: {
-        type: Boolean,
-        value: true,
-      },
-      _showFileTabContent: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabHeaderEndpoints: {
-        type: Array,
-      },
-      _showPrimaryTabs: {
-        type: Boolean,
-        computed: '_computeShowPrimaryTabs(_dynamicTabHeaderEndpoints)',
-      },
-      /** @type {Array<string>} */
-      _dynamicTabContentEndpoints: {
-        type: Array,
-      },
-      _selectedFilesTabPluginEndpoint: {
-        type: String,
-      },
-    },
+        /** @type {?} */
+        _mergeable: {
+          type: Boolean,
+          value: undefined,
+        },
+        _currentView: {
+          type: Number,
+          value: CommentTabs.CHANGE_LOG,
+        },
+        _showFileTabContent: {
+          type: Boolean,
+          value: true,
+        },
+        /** @type {Array<string>} */
+        _dynamicTabHeaderEndpoints: {
+          type: Array,
+        },
+        _showPrimaryTabs: {
+          type: Boolean,
+          computed: '_computeShowPrimaryTabs(_dynamicTabHeaderEndpoints)',
+        },
+        /** @type {Array<string>} */
+        _dynamicTabContentEndpoints: {
+          type: Array,
+        },
+        _selectedFilesTabPluginEndpoint: {
+          type: String,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
-    listeners: {
-      'topic-changed': '_handleTopicChanged',
-      // When an overlay is opened in a mobile viewport, the overlay has a full
-      // screen view. When it has a full screen view, we do not want the
-      // background to be scrollable. This will eliminate background scroll by
-      // hiding most of the contents on the screen upon opening, and showing
-      // again upon closing.
-      'fullscreen-overlay-opened': '_handleHideBackgroundContent',
-      'fullscreen-overlay-closed': '_handleShowBackgroundContent',
-      'diff-comments-modified': '_handleReloadCommentThreads',
-    },
-
-    observers: [
-      '_labelsChanged(_change.labels.*)',
-      '_paramsAndChangeChanged(params, _change)',
-      '_patchNumChanged(_patchRange.patchNum)',
-    ],
+    static get observers() {
+      return [
+        '_labelsChanged(_change.labels.*)',
+        '_paramsAndChangeChanged(params, _change)',
+        '_patchNumChanged(_patchRange.patchNum)',
+      ];
+    }
 
     keyboardShortcuts() {
       return {
@@ -325,9 +339,34 @@
         [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
         [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
       };
-    },
+    }
 
+    /** @override */
+    created() {
+      super.created();
+
+      this.addEventListener('topic-changed',
+          () => this._handleTopicChanged());
+
+      this.addEventListener(
+          // When an overlay is opened in a mobile viewport, the overlay has a full
+          // screen view. When it has a full screen view, we do not want the
+          // background to be scrollable. This will eliminate background scroll by
+          // hiding most of the contents on the screen upon opening, and showing
+          // again upon closing.
+          'fullscreen-overlay-opened',
+          () => this._handleHideBackgroundContent());
+
+      this.addEventListener('fullscreen-overlay-closed',
+          () => this._handleShowBackgroundContent());
+
+      this.addEventListener('diff-comments-modified',
+          () => this._handleReloadCommentThreads());
+    }
+
+    /** @override */
     attached() {
+      super.attached();
       this._getServerConfig().then(config => {
         this._serverConfig = config;
       });
@@ -342,16 +381,18 @@
         this._setDiffViewMode();
       });
 
-      Gerrit.awaitPluginsLoaded().then(() => {
-        this._dynamicTabHeaderEndpoints =
+      Gerrit.awaitPluginsLoaded()
+          .then(() => {
+            this._dynamicTabHeaderEndpoints =
             Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header');
-        this._dynamicTabContentEndpoints =
+            this._dynamicTabContentEndpoints =
             Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content');
-        if (this._dynamicTabContentEndpoints.length
-            !== this._dynamicTabHeaderEndpoints.length) {
-          console.warn('Different number of tab headers and tab content.');
-        }
-      }).then(() => this._setPrimaryTab());
+            if (this._dynamicTabContentEndpoints.length !==
+            this._dynamicTabHeaderEndpoints.length) {
+              console.warn('Different number of tab headers and tab content.');
+            }
+          })
+          .then(() => this._setPrimaryTab());
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
       this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
@@ -361,26 +402,32 @@
           this._handleCommitMessageSave.bind(this));
       this.addEventListener('editable-content-cancel',
           this._handleCommitMessageCancel.bind(this));
+      this.addEventListener('open-fix-preview',
+          this._onOpenFixPreview.bind(this));
+      this.addEventListener('close-fix-preview',
+          this._onCloseFixPreview.bind(this));
       this.listen(window, 'scroll', '_handleScroll');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'scroll', '_handleScroll');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
 
       if (this._updateCheckTimerHandle) {
         this._cancelUpdateCheckTimer();
       }
-    },
+    }
 
     get messagesList() {
       return this.$$('gr-messages-list');
-    },
+    }
 
     get threadList() {
       return this.$$('gr-thread-list');
-    },
+    }
 
     /**
      * @param {boolean=} opt_reset
@@ -388,16 +435,26 @@
     _setDiffViewMode(opt_reset) {
       if (!opt_reset && this.viewState.diffViewMode) { return; }
 
-      return this._getPreferences().then( prefs => {
-        if (!this.viewState.diffMode) {
-          this.set('viewState.diffMode', prefs.default_diff_view);
-        }
-      }).then(() => {
-        if (!this.viewState.diffMode) {
-          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-        }
-      });
-    },
+      return this._getPreferences()
+          .then( prefs => {
+            if (!this.viewState.diffMode) {
+              this.set('viewState.diffMode', prefs.default_diff_view);
+            }
+          })
+          .then(() => {
+            if (!this.viewState.diffMode) {
+              this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+            }
+          });
+    }
+
+    _onOpenFixPreview(e) {
+      this.$.applyFixDialog.open(e);
+    }
+
+    _onCloseFixPreview(e) {
+      this._reload();
+    }
 
     _handleToggleDiffMode(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -409,14 +466,19 @@
       } else {
         this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
       }
-    },
+    }
 
     _handleCommentTabChange() {
-      this._showMessagesView = this.$.commentTabs.selected === 0;
-    },
+      this._currentView = this.$.commentTabs.selected;
+    }
+
+    _isSelectedView(currentView, view) {
+      return currentView === view;
+    }
 
     _handleFileTabChange(e) {
-      const selectedIndex = this.$$('#primaryTabs').selected;
+      const selectedIndex = this.shadowRoot
+          .querySelector('#primaryTabs').selected;
       this._showFileTabContent = selectedIndex === 0;
       // Initial tab is the static files list.
       const newSelectedTab =
@@ -429,7 +491,7 @@
         this.$.reporting.reportInteraction('tab-changed',
             `tabname: ${tabName}, source: ${source}`);
       }
-    },
+    }
 
     _handleShowTab(e) {
       const idx = this._dynamicTabContentEndpoints.indexOf(e.detail.tab);
@@ -437,15 +499,15 @@
         console.warn(e.detail.tab + ' tab not found');
         return;
       }
-      this.$$('#primaryTabs').selected = idx + 1;
-      this.$$('#primaryTabs').scrollIntoView();
+      this.shadowRoot.querySelector('#primaryTabs').selected = idx + 1;
+      this.shadowRoot.querySelector('#primaryTabs').scrollIntoView();
       this.$.reporting.reportInteraction('show-tab', e.detail.tab);
-    },
+    }
 
     _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
-    },
+    }
 
     _handleCommitMessageSave(e) {
       // Trim trailing whitespace from each line.
@@ -463,18 +525,19 @@
             message);
         this._editingCommitMessage = false;
         this._reloadWindow();
-      }).catch(err => {
-        this.$.commitMessageEditor.disabled = false;
-      });
-    },
+      })
+          .catch(err => {
+            this.$.commitMessageEditor.disabled = false;
+          });
+    }
 
     _reloadWindow() {
       window.location.reload();
-    },
+    }
 
     _handleCommitMessageCancel(e) {
       this._editingCommitMessage = false;
-    },
+    }
 
     _computeChangeStatusChips(change, mergeable, submitEnabled) {
       // Polymer 2: check for undefined
@@ -498,17 +561,19 @@
         submitEnabled: !!submitEnabled,
       };
       return this.changeStatuses(change, options);
-    },
+    }
 
-    _computeHideEditCommitMessage(loggedIn, editing, change, editMode) {
+    _computeHideEditCommitMessage(
+        loggedIn, editing, change, editMode, collapsed, collapsible) {
       if (!loggedIn || editing ||
           (change && change.status === this.ChangeStatus.MERGED) ||
-          editMode) {
+          editMode ||
+          (collapsed && collapsible)) {
         return true;
       }
 
       return false;
-    },
+    }
 
     _handleReloadCommentThreads() {
       // Get any new drafts that have been saved in the diff view and show
@@ -518,7 +583,7 @@
             .map(c => Object.assign({}, c));
         Polymer.dom.flush();
       });
-    },
+    }
 
     _handleReloadDiffComments(e) {
       // Keeps the file list counts updated.
@@ -529,7 +594,7 @@
             e.detail.path);
         Polymer.dom.flush();
       });
-    },
+    }
 
     _computeTotalCommentCounts(unresolvedCount, changeComments) {
       if (!changeComments) return undefined;
@@ -543,7 +608,7 @@
           // Add a comma and space if both unresolved and draft comments exist.
           (unresolvedString && draftString ? ', ' : '') +
           draftString;
-    },
+    }
 
     _handleCommentSave(e) {
       const draft = e.detail.comment;
@@ -569,13 +634,13 @@
         }
       }
       diffDrafts[draft.path].push(draft);
-      diffDrafts[draft.path].sort((c1, c2) => {
+      diffDrafts[draft.path].sort((c1, c2) =>
         // No line number means that it’s a file comment. Sort it above the
         // others.
-        return (c1.line || -1) - (c2.line || -1);
-      });
+        (c1.line || -1) - (c2.line || -1)
+      );
       this._diffDrafts = diffDrafts;
-    },
+    }
 
     _handleCommentDiscard(e) {
       const draft = e.detail.comment;
@@ -609,16 +674,16 @@
         delete diffDrafts[draft.path];
       }
       this._diffDrafts = diffDrafts;
-    },
+    }
 
     _handleReplyTap(e) {
       e.preventDefault();
       this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-    },
+    }
 
     _handleOpenDiffPrefs() {
       this.$.fileList.openDiffPrefs();
-    },
+    }
 
     _handleOpenIncludedInDialog() {
       this.$.includedInDialog.loadData().then(() => {
@@ -626,11 +691,11 @@
         this.$.includedInOverlay.refit();
       });
       this.$.includedInOverlay.open();
-    },
+    }
 
     _handleIncludedInDialogClose(e) {
       this.$.includedInOverlay.close();
-    },
+    }
 
     _handleOpenDownloadDialog() {
       this.$.downloadOverlay.open().then(() => {
@@ -638,53 +703,56 @@
             .setFocusStops(this.$.downloadDialog.getFocusStops());
         this.$.downloadDialog.focus();
       });
-    },
+    }
 
     _handleDownloadDialogClose(e) {
       this.$.downloadOverlay.close();
-    },
+    }
 
     _handleOpenUploadHelpDialog(e) {
       this.$.uploadHelpOverlay.open();
-    },
+    }
 
     _handleCloseUploadHelpDialog(e) {
       this.$.uploadHelpOverlay.close();
-    },
+    }
 
     _handleMessageReply(e) {
       const msg = e.detail.message.message;
       const quoteStr = msg.split('\n').map(
-          line => { return '> ' + line; }).join('\n') + '\n\n';
+          line => '> ' + line)
+          .join('\n') + '\n\n';
       this.$.replyDialog.quote = quoteStr;
       this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
-    },
+    }
 
     _handleHideBackgroundContent() {
       this.$.mainContent.classList.add('overlayOpen');
-    },
+    }
 
     _handleShowBackgroundContent() {
       this.$.mainContent.classList.remove('overlayOpen');
-    },
+    }
 
     _handleReplySent(e) {
+      this.addEventListener('change-details-loaded',
+          () => {
+            this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+          }, {once: true});
       this.$.replyOverlay.close();
-      this._reload().then(() => {
-        this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
-      });
-    },
+      this._reload();
+    }
 
     _handleReplyCancel(e) {
       this.$.replyOverlay.close();
-    },
+    }
 
     _handleReplyAutogrow(e) {
       // If the textarea resizes, we need to re-fit the overlay.
       this.debounce('reply-overlay-refit', () => {
         this.$.replyOverlay.refit();
       }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
-    },
+    }
 
     _handleShowReplyDialog(e) {
       let target = this.$.replyDialog.FocusTarget.REVIEWERS;
@@ -692,25 +760,25 @@
         target = this.$.replyDialog.FocusTarget.CCS;
       }
       this._openReplyDialog(target);
-    },
+    }
 
     _handleScroll() {
       this.debounce('scroll', () => {
         this.viewState.scrollTop = document.body.scrollTop;
       }, 150);
-    },
+    }
 
     _setShownFiles(e) {
       this._shownFileCount = e.detail.length;
-    },
+    }
 
     _expandAllDiffs() {
       this.$.fileList.expandAllDiffs();
-    },
+    }
 
     _collapseAllDiffs() {
       this.$.fileList.collapseAllDiffs();
-    },
+    }
 
     _paramsChanged(value) {
       // Change the content of the comment tabs back to messages list, but
@@ -718,8 +786,7 @@
       // get messed up if changed here, because it requires the tabs to be on
       // the streen, and they are hidden shortly after this. The tab switching
       // animation will happen in post render tasks.
-      this._showMessagesView = true;
-
+      this._currentView = CommentTabs.CHANGE_LOG;
       if (value.view !== Gerrit.Nav.View.CHANGE) {
         this._initialLoadComplete = false;
         return;
@@ -764,7 +831,7 @@
       this._reload(true).then(() => {
         this._performPostLoadTasks();
       });
-    },
+    }
 
     _sendShowChangeEvent() {
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
@@ -772,15 +839,15 @@
         patchNum: this._patchRange.patchNum,
         info: {mergeable: this._mergeable},
       });
-    },
+    }
 
     _setPrimaryTab() {
       // Selected has to be set after the paper-tabs are visible because
       // the selected underline depends on calculations made by the browser.
       this.$.commentTabs.selected = 0;
-      const primaryTabs = this.$$('#primaryTabs');
+      const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
       if (primaryTabs) primaryTabs.selected = 0;
-    },
+    }
 
     _performPostLoadTasks() {
       this._maybeShowReplyDialog();
@@ -799,7 +866,7 @@
         }
         this._initialLoadComplete = true;
       });
-    },
+    }
 
     _paramsAndChangeChanged(value, change) {
       // Polymer 2: check for undefined
@@ -815,16 +882,16 @@
           patchRangeState.patchNum !== this._patchRange.patchNum) {
         this._resetFileListViewState();
       }
-    },
+    }
 
     _viewStateChanged(viewState) {
       this._numFilesShown = viewState.numFilesShown ?
         viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
-    },
+    }
 
     _numFilesShownChanged(numFilesShown) {
       this.viewState.numFilesShown = numFilesShown;
-    },
+    }
 
     _handleMessageAnchorTap(e) {
       const hash = MSG_PREFIX + e.detail.id;
@@ -832,18 +899,18 @@
           this._patchRange.patchNum, this._patchRange.basePatchNum,
           this._editMode, hash);
       history.replaceState(null, '', url);
-    },
+    }
 
     _maybeScrollToMessage(hash) {
       if (hash.startsWith(MSG_PREFIX)) {
         this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
       }
-    },
+    }
 
     _getLocationSearch() {
       // Not inlining to make it easier to test.
       return window.location.search;
-    },
+    }
 
     _getUrlParameter(param) {
       const pageURL = this._getLocationSearch().substring(1);
@@ -855,7 +922,7 @@
         }
       }
       return null;
-    },
+    }
 
     _maybeShowRevertDialog() {
       Gerrit.awaitPluginsLoaded()
@@ -871,7 +938,7 @@
               this.$.actions.showRevertDialog();
             }
           });
-    },
+    }
 
     _maybeShowReplyDialog() {
       this._getLoggedIn().then(loggedIn => {
@@ -885,7 +952,7 @@
           this.set('viewState.showReplyDialog', false);
         }
       });
-    },
+    }
 
     _resetFileListViewState() {
       this.set('viewState.selectedFileIndex', 0);
@@ -899,7 +966,7 @@
       }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
-    },
+    }
 
     _changeChanged(change) {
       if (!change || !this._patchRange || !this._allPatchSets) { return; }
@@ -915,7 +982,7 @@
 
       const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title});
-    },
+    }
 
     /**
      * Gets base patch number, if it is a parent try and decide from
@@ -948,19 +1015,19 @@
       }
 
       return 'PARENT';
-    },
+    }
 
     _computeShowPrimaryTabs(dynamicTabHeaderEndpoints) {
       return dynamicTabHeaderEndpoints && dynamicTabHeaderEndpoints.length > 0;
-    },
+    }
 
     _computeChangeUrl(change) {
       return Gerrit.Nav.getUrlForChange(change);
-    },
+    }
 
     _computeShowCommitInfo(changeStatus, current_revision) {
       return changeStatus === 'Merged' && current_revision;
-    },
+    }
 
     _computeMergedCommitInfo(current_revision, revisions) {
       const rev = revisions[current_revision];
@@ -969,11 +1036,11 @@
       // in <gr-commit-info>. @see Issue 5337
       if (!rev.commit.commit) { rev.commit.commit = current_revision; }
       return rev.commit;
-    },
+    }
 
     _computeChangeIdClass(displayChangeId) {
       return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
-    },
+    }
 
     _computeTitleAttributeWarning(displayChangeId) {
       if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
@@ -981,7 +1048,7 @@
       } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
         return 'No Change-Id in commit message';
       }
-    },
+    }
 
     _computeChangeIdCommitMessageError(commitMessage, change) {
       // Polymer 2: check for undefined
@@ -1011,11 +1078,11 @@
       }
       // There is no change-id in the commit message.
       return CHANGE_ID_ERROR.MISSING;
-    },
+    }
 
     _computeLabelNames(labels) {
       return Object.keys(labels).sort();
-    },
+    }
 
     _computeLabelValues(labelName, labels) {
       const result = [];
@@ -1040,7 +1107,7 @@
         }
       }
       return result;
-    },
+    }
 
     _computeReplyButtonLabel(changeRecord, canStartReview) {
       // Polymer 2: check for undefined
@@ -1053,16 +1120,15 @@
       }
 
       const drafts = (changeRecord && changeRecord.base) || {};
-      const draftCount = Object.keys(drafts).reduce((count, file) => {
-        return count + drafts[file].length;
-      }, 0);
+      const draftCount = Object.keys(drafts)
+          .reduce((count, file) => count + drafts[file].length, 0);
 
       let label = 'Reply';
       if (draftCount > 0) {
         label += ' (' + draftCount + ')';
       }
       return label;
-    },
+    }
 
     _handleOpenReplyDialog(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1078,7 +1144,7 @@
         e.preventDefault();
         this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
       });
-    },
+    }
 
     _handleOpenDownloadDialogShortcut(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1086,7 +1152,7 @@
 
       e.preventDefault();
       this.$.downloadOverlay.open();
-    },
+    }
 
     _handleEditTopic(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1094,13 +1160,13 @@
 
       e.preventDefault();
       this.$.metadata.editTopic();
-    },
+    }
 
     _handleRefreshChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       e.preventDefault();
       Gerrit.Nav.navigateToChange(this._change);
-    },
+    }
 
     _handleToggleChangeStar(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1108,7 +1174,7 @@
 
       e.preventDefault();
       this.$.changeStar.toggleStar();
-    },
+    }
 
     _handleUpToDashboard(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1116,7 +1182,7 @@
 
       e.preventDefault();
       this._determinePageBack();
-    },
+    }
 
     _handleExpandAllMessages(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1124,7 +1190,7 @@
 
       e.preventDefault();
       this.messagesList.handleExpandCollapse(true);
-    },
+    }
 
     _handleCollapseAllMessages(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1132,7 +1198,7 @@
 
       e.preventDefault();
       this.messagesList.handleExpandCollapse(false);
-    },
+    }
 
     _handleOpenDiffPrefsShortcut(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -1142,14 +1208,14 @@
 
       e.preventDefault();
       this.$.fileList.openDiffPrefs();
-    },
+    }
 
     _determinePageBack() {
       // Default backPage to root if user came to change view page
       // via an email link, etc.
       Gerrit.Nav.navigateToRelativeUrl(this.backPage ||
           Gerrit.Nav.getUrlForRoot());
-    },
+    }
 
     _handleLabelRemoved(splices, path) {
       for (const splice of splices) {
@@ -1164,7 +1230,7 @@
           }
         }
       }
-    },
+    }
 
     _labelsChanged(changeRecord) {
       if (!changeRecord) { return; }
@@ -1175,7 +1241,7 @@
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
         change: this._change,
       });
-    },
+    }
 
     /**
      * @param {string=} opt_section
@@ -1187,7 +1253,7 @@
         Polymer.dom.flush();
         this.$.replyOverlay.center();
       });
-    },
+    }
 
     _handleReloadChange(e) {
       return this._reload().then(() => {
@@ -1198,19 +1264,19 @@
           Gerrit.Nav.navigateToChange(this._change);
         }
       });
-    },
+    }
 
     _handleGetChangeDetailError(response) {
       this.fire('page-error', {response});
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _getServerConfig() {
       return this.$.restAPI.getConfig();
-    },
+    }
 
     _getProjectConfig() {
       if (!this._change) return;
@@ -1218,18 +1284,18 @@
           config => {
             this._projectConfig = config;
           });
-    },
+    }
 
     _getPreferences() {
       return this.$.restAPI.getPreferences();
-    },
+    }
 
     _prepareCommitMsgForLinkify(msg) {
       // TODO(wyatta) switch linkify sequence, see issue 5526.
       // This is a zero-with space. It is added to prevent the linkify library
       // from including R= or CC= as part of the email address.
       return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
-    },
+    }
 
     /**
      * Utility function to make the necessary modifications to a change in the
@@ -1259,7 +1325,7 @@
         change.revisions[edit.commit.commit].actions =
             change.revisions[edit.base_revision].actions;
       }
-    },
+    }
 
     _getChangeDetail() {
       const detailCompletes = this.$.restAPI.getChangeDetail(
@@ -1289,7 +1355,6 @@
               this._latestCommitMessage = null;
             }
 
-
             const lineHeight = getComputedStyle(this).lineHeight;
 
             // Slice returns a number as a string, convert to an int.
@@ -1314,16 +1379,16 @@
                       parseInt(this._patchRange.patchNum, 10));
             }
           });
-    },
+    }
 
     _isSubmitEnabled(revisionActions) {
       return !!(revisionActions && revisionActions.submit &&
         revisionActions.submit.enabled);
-    },
+    }
 
     _getEdit() {
       return this.$.restAPI.getChangeEdit(this._changeNum, true);
-    },
+    }
 
     _getLatestCommitMessage() {
       return this.$.restAPI.getChangeCommitInfo(this._changeNum,
@@ -1332,7 +1397,7 @@
         this._latestCommitMessage =
                     this._prepareCommitMsgForLinkify(commitInfo.message);
       });
-    },
+    }
 
     _getLatestRevisionSHA(change) {
       if (change.current_revision) {
@@ -1351,7 +1416,7 @@
         }
       }
       return latestRev;
-    },
+    }
 
     _getCommitInfo() {
       return this.$.restAPI.getChangeCommitInfo(
@@ -1359,13 +1424,11 @@
           commitInfo => {
             this._commitInfo = commitInfo;
           });
-    },
+    }
 
     _reloadDraftsWithCallback(e) {
-      return this._reloadDrafts().then(() => {
-        return e.detail.resolve();
-      });
-    },
+      return this._reloadDrafts().then(() => e.detail.resolve());
+    }
 
     /**
      * Fetches a new changeComment object, and data for all types of comments
@@ -1379,7 +1442,7 @@
             this._commentThreads = this._changeComments.getAllThreadsForChange()
                 .map(c => Object.assign({}, c));
           });
-    },
+    }
 
     /**
      * Fetches a new changeComment object, but only updated data for drafts is
@@ -1391,7 +1454,7 @@
             this._changeComments = comments;
             this._diffDrafts = Object.assign({}, this._changeComments.drafts);
           });
-    },
+    }
 
     /**
      * Reload the change.
@@ -1419,7 +1482,11 @@
       // Resolves when the loading flag is set to false, meaning that some
       // change content may start appearing.
       const loadingFlagSet = detailCompletes
-          .then(() => { this._loading = false; })
+          .then(() => {
+            this._loading = false;
+            this.dispatchEvent(new CustomEvent('change-details-loaded',
+                {bubbles: true, composed: true}));
+          })
           .then(() => {
             this.$.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
             if (opt_isLocationChange) {
@@ -1440,7 +1507,7 @@
       let coreDataPromise;
 
       // If the patch number is specified
-      if (this._patchRange.patchNum) {
+      if (this._patchRange && this._patchRange.patchNum) {
         // Because a specific patchset is specified, reload the resources that
         // are keyed by patch number or patch range.
         const patchResourcesLoaded = this._reloadPatchNumDependentResources();
@@ -1499,7 +1566,7 @@
       });
 
       return coreDataPromise;
-    },
+    }
 
     /**
      * Kicks off requests for resources that rely on the patch range
@@ -1510,7 +1577,7 @@
         this._getCommitInfo(),
         this.$.fileList.reload(),
       ]);
-    },
+    }
 
     _getMergeability() {
       if (!this._change) {
@@ -1530,59 +1597,69 @@
       return this.$.restAPI.getMergeable(this._changeNum).then(m => {
         this._mergeable = m.mergeable;
       });
-    },
+    }
 
     _computeCanStartReview(change) {
       return !!(change.actions && change.actions.ready &&
           change.actions.ready.enabled);
-    },
+    }
 
-    _computeReplyDisabled() { return false; },
+    _computeReplyDisabled() { return false; }
 
     _computeChangePermalinkAriaLabel(changeNum) {
       return 'Change ' + changeNum;
-    },
+    }
 
-    _computeCommitClass(collapsed, commitMessage) {
-      if (this._computeCommitToggleHidden(commitMessage)) { return ''; }
-      return collapsed ? 'collapsed' : '';
-    },
+    _computeCommitMessageCollapsed(collapsed, collapsible) {
+      return collapsible && collapsed;
+    }
 
     _computeRelatedChangesClass(collapsed) {
       return collapsed ? 'collapsed' : '';
-    },
+    }
 
     _computeCollapseText(collapsed) {
       // Symbols are up and down triangles.
       return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-    },
+    }
+
+    /**
+     * Returns the text to be copied when
+     * click the copy icon next to change subject
+     *
+     * @param {!Object} change
+     */
+    _computeCopyTextForTitle(change) {
+      return `${change._number}: ${change.subject}` +
+       ` | https://${location.host}${this._computeChangeUrl(change)}`;
+    }
 
     _toggleCommitCollapsed() {
       this._commitCollapsed = !this._commitCollapsed;
       if (this._commitCollapsed) {
         window.scrollTo(0, 0);
       }
-    },
+    }
 
     _toggleRelatedChangesCollapsed() {
       this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
       if (this._relatedChangesCollapsed) {
         window.scrollTo(0, 0);
       }
-    },
+    }
 
-    _computeCommitToggleHidden(commitMessage) {
-      if (!commitMessage) { return true; }
-      return commitMessage.split('\n').length < MIN_LINES_FOR_COMMIT_COLLAPSE;
-    },
+    _computeCommitCollapsible(commitMessage) {
+      if (!commitMessage) { return false; }
+      return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+    }
 
     _getOffsetHeight(element) {
       return element.offsetHeight;
-    },
+    }
 
     _getScrollHeight(element) {
       return element.scrollHeight;
-    },
+    }
 
     /**
      * Get the line height of an element to the nearest integer.
@@ -1590,7 +1667,7 @@
     _getLineHeight(element) {
       const lineHeightStr = getComputedStyle(element).lineHeight;
       return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
-    },
+    }
 
     /**
      * New max height for the related changes section, shorter than the existing
@@ -1601,8 +1678,6 @@
       // bottom margin.
       const EXTRA_HEIGHT = 30;
       let newHeight;
-      const hasCommitToggle =
-          !this._computeCommitToggleHidden(this._latestCommitMessage);
 
       if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
           .matches) {
@@ -1621,7 +1696,7 @@
             MINIMUM_RELATED_MAX_HEIGHT);
         newHeight = medRelatedHeight;
       } else {
-        if (hasCommitToggle) {
+        if (this._commitCollapsible) {
           // Make sure the content is lined up if both areas have buttons. If
           // the commit message is not collapsed, instead use the change info
           // height.
@@ -1644,12 +1719,12 @@
       stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
 
       // Update the max-height of the relation chain to this new height.
-      if (hasCommitToggle) {
+      if (this._commitCollapsible) {
         stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
       }
 
       this.updateStyles(stylesToUpdate);
-    },
+    }
 
     _computeShowRelatedToggle() {
       // Make sure the max height has been applied, since there is now content
@@ -1669,7 +1744,7 @@
         return this._showRelatedToggle = true;
       }
       this._showRelatedToggle = false;
-    },
+    }
 
     _updateToggleContainerClass(showRelatedToggle) {
       if (showRelatedToggle) {
@@ -1677,7 +1752,7 @@
       } else {
         this.$.relatedChangesToggle.classList.remove('showToggle');
       }
-    },
+    }
 
     _startUpdateCheckTimer() {
       if (!this._serverConfig ||
@@ -1720,14 +1795,14 @@
           });
         });
       }, this._serverConfig.change.update_delay * 1000);
-    },
+    }
 
     _cancelUpdateCheckTimer() {
       if (this._updateCheckTimerHandle) {
         this.cancelAsync(this._updateCheckTimerHandle);
       }
       this._updateCheckTimerHandle = null;
-    },
+    }
 
     _handleVisibilityChange() {
       if (document.hidden && this._updateCheckTimerHandle) {
@@ -1735,17 +1810,17 @@
       } else if (!this._updateCheckTimerHandle) {
         this._startUpdateCheckTimer();
       }
-    },
+    }
 
     _handleTopicChanged() {
       this.$.relatedChanges.reload();
-    },
+    }
 
     _computeHeaderClass(editMode) {
       const classes = ['header'];
       if (editMode) { classes.push('editMode'); }
       return classes.join(' ');
-    },
+    }
 
     _computeEditMode(patchRangeRecord, paramsRecord) {
       if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
@@ -1756,7 +1831,7 @@
 
       const patchRange = patchRangeRecord.base || {};
       return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
-    },
+    }
 
     _handleFileActionTap(e) {
       e.preventDefault();
@@ -1778,23 +1853,24 @@
           controls.openRestoreDialog(path);
           break;
       }
-    },
+    }
 
     _computeCommitMessageKey(number, revision) {
       return `c${number}_rev${revision}`;
-    },
+    }
 
     _patchNumChanged(patchNumStr) {
       if (!this._selectedRevision) {
         return;
       }
-      const patchNum = parseInt(patchNumStr, 10);
+      // If patchNumStr is"edit", then patchNum is undefined hence an OR
+      const patchNum = parseInt(patchNumStr, 10) || patchNumStr;
       if (patchNum === this._selectedRevision._number) {
         return;
       }
       this._selectedRevision = Object.values(this._change.revisions).find(
           revision => revision._number === patchNum);
-    },
+    }
 
     /**
      * If an edit exists already, load it. Otherwise, toggle edit mode via the
@@ -1817,31 +1893,33 @@
         patchNum = this._patchRange.patchNum;
       }
       Gerrit.Nav.navigateToChange(this._change, patchNum, null, true);
-    },
+    }
 
     _handleStopEditTap() {
       Gerrit.Nav.navigateToChange(this._change, this._patchRange.patchNum);
-    },
+    }
 
     _resetReplyOverlayFocusStops() {
       this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-    },
+    }
 
     _handleToggleStar(e) {
       this.$.restAPI.saveChangeStarred(e.detail.change._number,
           e.detail.starred);
-    },
+    }
 
     _getRevisionInfo(change) {
       return new Gerrit.RevisionInfo(change);
-    },
+    }
 
     _computeCurrentRevision(currentRevision, revisions) {
       return currentRevision && revisions && revisions[currentRevision];
-    },
+    }
 
     _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
       return disableDiffPrefs || !loggedIn;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeView.is, GrChangeView);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index e00bab5..5d38c2d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -63,6 +63,11 @@
     let navigateToChangeStub;
     const TEST_SCROLL_TOP_PX = 100;
 
+    const CommentTabs = {
+      CHANGE_LOG: 0,
+      COMMENT_THREADS: 1,
+    };
+
     setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-endpoint-decorator', {
@@ -91,9 +96,7 @@
       });
     });
 
-    getCustomCssValue = cssParam => {
-      return util.getComputedStyleValue(cssParam, element);
-    };
+    getCustomCssValue = cssParam => util.getComputedStyleValue(cssParam, element);
 
     test('_handleMessageAnchorTap', () => {
       element._changeNum = '1';
@@ -377,7 +380,7 @@
 
     test('thread list modified', () => {
       sandbox.spy(element, '_handleReloadDiffComments');
-      element._showMessagesView = false;
+      element._currentView = CommentTabs.COMMENT_THREADS;
       flushAsynchronousOperations();
 
       return element._reloadComments().then(() => {
@@ -444,18 +447,18 @@
       // Wait for tab to get selected
       flush(() => {
         assert.equal(element.$.commentTabs.selected, 0);
-        assert.isTrue(element._showMessagesView);
+        assert.equal(element._currentView, CommentTabs.CHANGE_LOG);
         // Switch to comment thread tab
         MockInteractions.tap(element.$$('paper-tab.commentThreads'));
         assert.equal(element.$.commentTabs.selected, 1);
-        assert.isFalse(element._showMessagesView);
+        assert.equal(element._currentView, CommentTabs.COMMENT_THREADS);
 
         // When the change is partially reloaded (ex: Shift+R), the content
         // is swapped out before the tab, so messages list will display even
         // though the tab for comment threads is still temporarily selected.
         element._paramsChanged(element.params);
         assert.equal(element.$.commentTabs.selected, 1);
-        assert.isTrue(element._showMessagesView);
+        assert.equal(element._currentView, CommentTabs.CHANGE_LOG);
         done();
       });
     });
@@ -725,10 +728,10 @@
 
     test('don’t reload entire page when patchRange changes', () => {
       const reloadStub = sandbox.stub(element, '_reload',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
       const reloadPatchDependentStub = sandbox.stub(element,
           '_reloadPatchNumDependentResources',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
       const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
       const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
 
@@ -753,7 +756,7 @@
 
     test('reload entire page when patchRange doesnt change', () => {
       const reloadStub = sandbox.stub(element, '_reload',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
       const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
       const value = {
         view: Gerrit.Nav.View.CHANGE,
@@ -769,11 +772,9 @@
     test('related changes are updated and new patch selected after rebase',
         done => {
           element._changeNum = '42';
-          sandbox.stub(element, 'computeLatestPatchNum', () => {
-            return 1;
-          });
+          sandbox.stub(element, 'computeLatestPatchNum', () => 1);
           sandbox.stub(element, '_reload',
-              () => { return Promise.resolve(); });
+              () => Promise.resolve());
           const e = {detail: {action: 'rebase'}};
           element._handleReloadChange(e).then(() => {
             assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
@@ -783,7 +784,7 @@
         });
 
     test('related changes are not updated after other action', done => {
-      sandbox.stub(element, '_reload', () => { return Promise.resolve(); });
+      sandbox.stub(element, '_reload', () => Promise.resolve());
       sandbox.stub(element.$.relatedChanges, 'reload');
       const e = {detail: {action: 'abandon'}};
       element._handleReloadChange(e).then(() => {
@@ -807,6 +808,24 @@
       assert.deepEqual(commit, {commit: 2});
     });
 
+    test('_computeCopyTextForTitle', () => {
+      const change = {
+        _number: 123,
+        subject: 'test subject',
+        revisions: {
+          rev1: {_number: 1},
+          rev3: {_number: 3},
+        },
+        current_revision: 'rev3',
+      };
+      sandbox.stub(Gerrit.Nav, 'getUrlForChange')
+          .returns('/change/123');
+      assert.equal(
+          element._computeCopyTextForTitle(change),
+          '123: test subject | https://localhost:8081/change/123'
+      );
+    });
+
     test('get latest revision', () => {
       let change = {
         revisions: {
@@ -929,14 +948,12 @@
 
     test('topic is coalesced to null', done => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
-        return Promise.resolve({
-          id: '123456789',
-          labels: {},
-          current_revision: 'foo',
-          revisions: {foo: {commit: {}}},
-        });
-      });
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+        id: '123456789',
+        labels: {},
+        current_revision: 'foo',
+        revisions: {foo: {commit: {}}},
+      }));
 
       element._getChangeDetail().then(() => {
         assert.isNull(element._change.topic);
@@ -946,14 +963,12 @@
 
     test('commit sha is populated from getChangeDetail', done => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
-        return Promise.resolve({
-          id: '123456789',
-          labels: {},
-          current_revision: 'foo',
-          revisions: {foo: {commit: {}}},
-        });
-      });
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+        id: '123456789',
+        labels: {},
+        current_revision: 'foo',
+        revisions: {foo: {commit: {}}},
+      }));
 
       element._getChangeDetail().then(() => {
         assert.equal('foo', element._commitInfo.commit);
@@ -963,20 +978,16 @@
 
     test('edit is added to change', () => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
-        return Promise.resolve({
-          id: '123456789',
-          labels: {},
-          current_revision: 'foo',
-          revisions: {foo: {commit: {}}},
-        });
-      });
-      sandbox.stub(element, '_getEdit', () => {
-        return Promise.resolve({
-          base_patch_set_number: 1,
-          commit: {commit: 'bar'},
-        });
-      });
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
+        id: '123456789',
+        labels: {},
+        current_revision: 'foo',
+        revisions: {foo: {commit: {}}},
+      }));
+      sandbox.stub(element, '_getEdit', () => Promise.resolve({
+        base_patch_set_number: 1,
+        commit: {commit: 'bar'},
+      }));
       element._patchRange = {};
 
       return element._getChangeDetail().then(() => {
@@ -1097,12 +1108,8 @@
     });
 
     test('revert dialog opened with revert param', done => {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn', () => {
-        return Promise.resolve(true);
-      });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => {
-        return Promise.resolve();
-      });
+      sandbox.stub(element.$.restAPI, 'getLoggedIn', () => Promise.resolve(true));
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => Promise.resolve());
 
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -1173,7 +1180,7 @@
       setup(() => {
         sandbox.stub(element.$.replyDialog, '_draftChanged');
         sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
-            () => { return Promise.resolve({isLatest: true}); });
+            () => Promise.resolve({isLatest: true}));
         element._change = {labels: {}};
       });
 
@@ -1222,7 +1229,7 @@
     suite('commit message expand/collapse', () => {
       setup(() => {
         sandbox.stub(element, 'fetchChangeUpdates',
-            () => { return Promise.resolve({isLatest: false}); });
+            () => Promise.resolve({isLatest: false}));
       });
 
       test('commitCollapseToggle hidden for short commit message', () => {
@@ -1236,14 +1243,16 @@
       });
 
       test('commitCollapseToggle functions', () => {
-        element._latestCommitMessage = _.times(31, String).join('\n');
+        element._latestCommitMessage = _.times(35, String).join('\n');
         assert.isTrue(element._commitCollapsed);
+        assert.isTrue(element._commitCollapsible);
         assert.isTrue(
-            element.$.commitMessage.classList.contains('collapsed'));
+            element.$.commitMessageEditor.hasAttribute('collapsed'));
         MockInteractions.tap(element.$.commitCollapseToggleButton);
         assert.isFalse(element._commitCollapsed);
+        assert.isTrue(element._commitCollapsible);
         assert.isFalse(
-            element.$.commitMessage.classList.contains('collapsed'));
+            element.$.commitMessageEditor.hasAttribute('collapsed'));
       });
     });
 
@@ -1260,7 +1269,7 @@
             sandbox.stub(element, '_getOffsetHeight', () => 50);
             sandbox.stub(element, '_getScrollHeight', () => 60);
             sandbox.stub(element, '_getLineHeight', () => 5);
-            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+            sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
             element.$.relatedChanges.dispatchEvent(
                 new CustomEvent('new-section-loaded'));
             assert.isTrue(element.$.relatedChangesToggle.classList
@@ -1275,7 +1284,7 @@
             sandbox.stub(element, '_getOffsetHeight', () => 50);
             sandbox.stub(element, '_getScrollHeight', () => 40);
             sandbox.stub(element, '_getLineHeight', () => 5);
-            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+            sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
             element.$.relatedChanges.dispatchEvent(
                 new CustomEvent('new-section-loaded'));
             assert.isFalse(element.$.relatedChangesToggle.classList
@@ -1285,7 +1294,7 @@
 
       test('relatedChangesToggle functions', () => {
         sandbox.stub(element, '_getOffsetHeight', () => 50);
-        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
+        sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
         element._relatedChangesLoading = false;
         assert.isTrue(element._relatedChangesCollapsed);
         assert.isTrue(
@@ -1299,7 +1308,7 @@
       test('_updateRelatedChangeMaxHeight without commit toggle', () => {
         sandbox.stub(element, '_getOffsetHeight', () => 50);
         sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
+        sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
 
         // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
         // 20 (max existing height)  % 12 (line height) = 6 (remainder).
@@ -1316,7 +1325,7 @@
         element._latestCommitMessage = _.times(31, String).join('\n');
         sandbox.stub(element, '_getOffsetHeight', () => 50);
         sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
+        sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
 
         // 50 (existing height) % 12 (line height) = 2 (remainder).
         // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
@@ -1332,7 +1341,7 @@
         element._latestCommitMessage = _.times(31, String).join('\n');
         sandbox.stub(element, '_getOffsetHeight', () => 50);
         sandbox.stub(element, '_getLineHeight', () => 12);
-        sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+        sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
 
         element._updateRelatedChangeMaxHeight();
 
@@ -1362,7 +1371,6 @@
             '96px');
       });
 
-
       suite('update checks', () => {
         setup(() => {
           sandbox.spy(element, '_startUpdateCheckTimer');
@@ -1384,7 +1392,7 @@
 
         test('_startUpdateCheckTimer up-to-date', () => {
           sandbox.stub(element, 'fetchChangeUpdates',
-              () => { return Promise.resolve({isLatest: true}); });
+              () => Promise.resolve({isLatest: true}));
 
           element._serverConfig = {change: {update_delay: 12345}};
 
@@ -1395,7 +1403,7 @@
 
         test('_startUpdateCheckTimer out-of-date shows an alert', done => {
           sandbox.stub(element, 'fetchChangeUpdates',
-              () => { return Promise.resolve({isLatest: false}); });
+              () => Promise.resolve({isLatest: false}));
           element.addEventListener('show-alert', e => {
             assert.equal(e.detail.message,
                 'A newer patch set has been uploaded');
@@ -1618,6 +1626,30 @@
       });
     });
 
+    test('_selectedRevision is assigned when patchNum is edit', () => {
+      const revision1 = {_number: 1, commit: {parents: []}};
+      const revision2 = {_number: 2, commit: {parents: []}};
+      const revision3 = {_number: 'edit', commit: {parents: []}};
+      sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
+          Promise.resolve({
+            revisions: {
+              aaa: revision1,
+              bbb: revision2,
+              ccc: revision3,
+            },
+            labels: {},
+            actions: {},
+            current_revision: 'ccc',
+            change_id: 'loremipsumdolorsitamet',
+          }));
+      sandbox.stub(element, '_getEdit').returns(Promise.resolve());
+      sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
+      element._patchRange = {patchNum: 'edit'};
+      return element._getChangeDetail().then(() => {
+        assert.strictEqual(element._selectedRevision, revision3);
+      });
+    });
+
     test('_sendShowChangeEvent', () => {
       element._change = {labels: {}};
       element._patchRange = {patchNum: 4};
@@ -1715,8 +1747,9 @@
         Gerrit.install(
             p => {
               plugin = p;
-              plugin.hook('change-view-integration').getLastAttached().then(
-                  el => hookEl = el);
+              plugin.hook('change-view-integration').getLastAttached()
+                  .then(
+                      el => hookEl = el);
             },
             '0.1',
             'http://some/plugins/url.html');
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 42cb976..58bdbbc 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -16,32 +16,41 @@
  */
 (function() {
   'use strict';
-  Polymer({
-    is: 'gr-comment-list',
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrCommentList extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.PathListBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-comment-list'; }
 
-    properties: {
-      changeNum: Number,
-      comments: Object,
-      patchNum: Number,
-      projectName: String,
-      /** @type {?} */
-      projectConfig: Object,
-    },
+    static get properties() {
+      return {
+        changeNum: Number,
+        comments: Object,
+        patchNum: Number,
+        projectName: String,
+        /** @type {?} */
+        projectConfig: Object,
+      };
+    }
 
     _computeFilesFromComments(comments) {
       const arr = Object.keys(comments || {});
       return arr.sort(this.specialFilePathCompare);
-    },
+    }
 
     _isOnParent(comment) {
       return comment.side === 'PARENT';
-    },
+    }
 
     _computeDiffLineURL(file, changeNum, patchNum, comment) {
       const basePatchNum = comment.hasOwnProperty('parent') ?
@@ -49,13 +58,13 @@
       return Gerrit.Nav.getUrlForDiffById(this.changeNum, this.projectName,
           file, patchNum, basePatchNum, comment.line,
           this._isOnParent(comment));
-    },
+    }
 
     _computeCommentsForFile(comments, file) {
       // Changes are not picked up by the dom-repeat due to the array instance
       // identity not changing even when it has elements added/removed from it.
       return (comments[file] || []).slice();
-    },
+    }
 
     _computePatchDisplayName(comment) {
       if (this._isOnParent(comment)) {
@@ -65,6 +74,8 @@
         return `PS${comment.patch_set}, `;
       }
       return '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCommentList.is, GrCommentList);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index c18ae8d..97be9b7 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index e2fcdff..a339865 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -17,23 +17,28 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-commit-info',
+  /** @extends Polymer.Element */
+  class GrCommitInfo extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-commit-info'; }
 
-    properties: {
-      change: Object,
-      /** @type {?} */
-      commitInfo: Object,
-      serverConfig: Object,
-      _showWebLink: {
-        type: Boolean,
-        computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
-      },
-      _webLink: {
-        type: String,
-        computed: '_computeWebLink(change, commitInfo, serverConfig)',
-      },
-    },
+    static get properties() {
+      return {
+        change: Object,
+        /** @type {?} */
+        commitInfo: Object,
+        serverConfig: Object,
+        _showWebLink: {
+          type: Boolean,
+          computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
+        },
+        _webLink: {
+          type: String,
+          computed: '_computeWebLink(change, commitInfo, serverConfig)',
+        },
+      };
+    }
 
     _getWeblink(change, commitInfo, config) {
       return Gerrit.Nav.getPatchSetWeblink(
@@ -43,7 +48,7 @@
             weblinks: commitInfo.web_links,
             config,
           });
-    },
+    }
 
     _computeShowWebLink(change, commitInfo, serverConfig) {
       // Polymer 2: check for undefined
@@ -53,7 +58,7 @@
 
       const weblink = this._getWeblink(change, commitInfo, serverConfig);
       return !!weblink && !!weblink.url;
-    },
+    }
 
     _computeWebLink(change, commitInfo, serverConfig) {
       // Polymer 2: check for undefined
@@ -63,12 +68,14 @@
 
       const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
       return url;
-    },
+    }
 
     _computeShortHash(commitInfo) {
       const {name} =
             this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
       return name;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCommitInfo.is, GrCommitInfo);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index f271a70..2c5b431 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-commit-info</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -101,7 +101,6 @@
           element.serverConfig), 'https://link-url');
     });
 
-
     test('ignore web links that are neither gitweb nor gitiles', () => {
       const router = document.createElement('gr-router');
       sandbox.stub(Gerrit.Nav, '_generateWeblinks',
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index 05a2bb2..9e7857c4 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -45,14 +45,7 @@
         font-family: var(--monospace-font-family);
         font-size: var(--font-size-mono);
         line-height: var(--line-height-mono);
-        padding: 0;
         width: 73ch; /* Add a char to account for the border. */
-
-        --iron-autogrow-textarea {
-          border: 1px solid var(--border-color);
-          box-sizing: border-box;
-          font-family: var(--monospace-font-family);
-        }
       }
     </style>
     <gr-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index 524876e..555c605 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -17,9 +17,18 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-confirm-abandon-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmAbandonDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-abandon-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -32,41 +41,42 @@
      * @event cancel
      */
 
-    properties: {
-      message: String,
-    },
+    static get properties() {
+      return {
+        message: String,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    },
+    get keyBindings() {
+      return {
+        'ctrl+enter meta+enter': '_handleEnterKey',
+      };
+    }
 
     resetFocus() {
       this.$.messageInput.textarea.focus();
-    },
+    }
 
     _handleEnterKey(e) {
       this._confirm();
-    },
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this._confirm();
-    },
+    }
 
     _confirm() {
       this.fire('confirm', {reason: this.message}, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmAbandonDialog.is, GrConfirmAbandonDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
index cc4b80e..e9964c5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-abandon-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
index a0da331..35e9afb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
@@ -17,12 +17,16 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-confirm-cherrypick-conflict-dialog',
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmCherrypickConflictDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-cherrypick-conflict-dialog'; }
 
     /**
      * Fired when the confirm button is pressed.
@@ -40,12 +44,15 @@
       e.preventDefault();
       e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmCherrypickConflictDialog.is,
+      GrConfirmCherrypickConflictDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
index f411de4..557972a 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-conflict-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index 8ddcd83..cab9fd6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -47,18 +47,11 @@
         display: block;
         width: 100%;
       }
-      .main .message {
-        width: 100%;
-      }
       iron-autogrow-textarea {
         font-family: var(--monospace-font-family);
         font-size: var(--font-size-mono);
         line-height: var(--line-height-mono);
-        padding: 0;
-        --iron-autogrow-textarea: {
-          font-family: var(--monospace-font-family);
-          width: 72ch;
-        };
+        width: 73ch; /* Add a char to account for the border. */
       }
     </style>
     <gr-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 5278540..2b10a97 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -19,9 +19,16 @@
 
   const SUGGESTIONS_LIMIT = 15;
 
-  Polymer({
-    is: 'gr-confirm-cherrypick-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmCherrypickDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-cherrypick-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -34,29 +41,29 @@
      * @event cancel
      */
 
-    properties: {
-      branch: String,
-      baseCommit: String,
-      changeStatus: String,
-      commitMessage: String,
-      commitNum: String,
-      message: String,
-      project: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getProjectBranchesSuggestions.bind(this);
+    static get properties() {
+      return {
+        branch: String,
+        baseCommit: String,
+        changeStatus: String,
+        commitMessage: String,
+        commitNum: String,
+        message: String,
+        project: String,
+        _query: {
+          type: Function,
+          value() {
+            return this._getProjectBranchesSuggestions.bind(this);
+          },
         },
-      },
-    },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
-      '_computeMessage(changeStatus, commitNum, commitMessage)',
-    ],
+    static get observers() {
+      return [
+        '_computeMessage(changeStatus, commitNum, commitMessage)',
+      ];
+    }
 
     _computeMessage(changeStatus, commitNum, commitMessage) {
       // Polymer 2: check for undefined
@@ -74,23 +81,23 @@
         newMessage += '(cherry picked from commit ' + commitNum + ')';
       }
       this.message = newMessage;
-    },
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
+    }
 
     resetFocus() {
       this.$.branchInput.focus();
-    },
+    }
 
     _getProjectBranchesSuggestions(input) {
       if (input.startsWith('refs/heads/')) {
@@ -113,6 +120,9 @@
         }
         return branches;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmCherrypickDialog.is,
+      GrConfirmCherrypickDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 22a2aba..42310fb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-cherrypick-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
index 24c1132..f65ec03 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -37,9 +37,6 @@
       label {
         cursor: pointer;
       }
-      iron-autogrow-textarea {
-        padding: 0;
-      }
       .main {
         display: flex;
         flex-direction: column;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index c6b0adf..feb3d2f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -19,9 +19,16 @@
 
   const SUGGESTIONS_LIMIT = 15;
 
-  Polymer({
-    is: 'gr-confirm-move-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmMoveDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-move-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -34,33 +41,31 @@
      * @event cancel
      */
 
-    properties: {
-      branch: String,
-      message: String,
-      project: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getProjectBranchesSuggestions.bind(this);
+    static get properties() {
+      return {
+        branch: String,
+        message: String,
+        project: String,
+        _query: {
+          type: Function,
+          value() {
+            return this._getProjectBranchesSuggestions.bind(this);
+          },
         },
-      },
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+      };
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
+    }
 
     _getProjectBranchesSuggestions(input) {
       if (input.startsWith('refs/heads/')) {
@@ -83,6 +88,8 @@
         }
         return branches;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmMoveDialog.is, GrConfirmMoveDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
index 8d6e029..036950b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-move-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 54ce271..607f587 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -17,9 +17,11 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-confirm-rebase-dialog',
-
+  /** @extends Polymer.Element */
+  class GrConfirmRebaseDialog extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-confirm-rebase-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -32,24 +34,28 @@
      * @event cancel
      */
 
-    properties: {
-      branch: String,
-      changeNumber: Number,
-      hasParent: Boolean,
-      rebaseOnCurrent: Boolean,
-      _text: String,
-      _query: {
-        type: Function,
-        value() {
-          return this._getChangeSuggestions.bind(this);
+    static get properties() {
+      return {
+        branch: String,
+        changeNumber: Number,
+        hasParent: Boolean,
+        rebaseOnCurrent: Boolean,
+        _text: String,
+        _query: {
+          type: Function,
+          value() {
+            return this._getChangeSuggestions.bind(this);
+          },
         },
-      },
-      _recentChanges: Array,
-    },
+        _recentChanges: Array,
+      };
+    }
 
-    observers: [
-      '_updateSelectedOption(rebaseOnCurrent, hasParent)',
-    ],
+    static get observers() {
+      return [
+        '_updateSelectedOption(rebaseOnCurrent, hasParent)',
+      ];
+    }
 
     // This is called by gr-change-actions every time the rebase dialog is
     // re-opened. Unlike other autocompletes that make a request with each
@@ -71,36 +77,36 @@
             this._recentChanges = changes;
             return this._recentChanges;
           });
-    },
+    }
 
     _getRecentChanges() {
       if (this._recentChanges) {
         return Promise.resolve(this._recentChanges);
       }
       return this.fetchRecentChanges();
-    },
+    }
 
     _getChangeSuggestions(input) {
       return this._getRecentChanges().then(changes =>
         this._filterChanges(input, changes));
-    },
+    }
 
     _filterChanges(input, changes) {
       return changes.filter(change => change.name.includes(input) &&
           change.value !== this.changeNumber);
-    },
+    }
 
     _displayParentOption(rebaseOnCurrent, hasParent) {
       return hasParent && rebaseOnCurrent;
-    },
+    }
 
     _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
       return hasParent && !rebaseOnCurrent;
-    },
+    }
 
     _displayTipOption(rebaseOnCurrent, hasParent) {
       return !(!rebaseOnCurrent && !hasParent);
-    },
+    }
 
     /**
      * There is a subtle but important difference between setting the base to an
@@ -115,7 +121,7 @@
       // Change numbers will have their description appended by the
       // autocomplete.
       return this._text.split(':')[0];
-    },
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
@@ -123,22 +129,22 @@
       this.dispatchEvent(new CustomEvent('confirm',
           {detail: {base: this._getSelectedBase()}}));
       this._text = '';
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.dispatchEvent(new CustomEvent('cancel'));
       this._text = '';
-    },
+    }
 
     _handleRebaseOnOther() {
       this.$.parentInput.focus();
-    },
+    }
 
     _handleEnterChangeNumberClick() {
       this.$.rebaseOnOtherInput.checked = true;
-    },
+    }
 
     /**
      * Sets the default radio button based on the state of the app and
@@ -157,6 +163,8 @@
       } else {
         this.$.rebaseOnOtherInput.checked = true;
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmRebaseDialog.is, GrConfirmRebaseDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index cd5b130..09953ce 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-rebase-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -153,15 +153,17 @@
 
       test('_getRecentChanges', () => {
         sandbox.spy(element, '_getRecentChanges');
-        return element._getRecentChanges().then(() => {
-          assert.deepEqual(element._recentChanges, recentChanges);
-          assert.equal(element.$.restAPI.getChanges.callCount, 1);
-          // When called a second time, should not re-request recent changes.
-          element._getRecentChanges();
-        }).then(() => {
-          assert.equal(element._getRecentChanges.callCount, 2);
-          assert.equal(element.$.restAPI.getChanges.callCount, 1);
-        });
+        return element._getRecentChanges()
+            .then(() => {
+              assert.deepEqual(element._recentChanges, recentChanges);
+              assert.equal(element.$.restAPI.getChanges.callCount, 1);
+              // When called a second time, should not re-request recent changes.
+              element._getRecentChanges();
+            })
+            .then(() => {
+              assert.equal(element._getRecentChanges.callCount, 2);
+              assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            });
       });
 
       test('_filterChanges', () => {
@@ -182,6 +184,11 @@
       test('input text change triggers function', () => {
         sandbox.spy(element, '_getRecentChanges');
         element.$.parentInput.noDebounce = true;
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.parentInput.$.input,
+            13,
+            null,
+            'enter');
         element._text = '1';
         assert.isTrue(element._getRecentChanges.calledOnce);
         element._text = '12';
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 2e1e6ae..144cf20 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -21,6 +21,7 @@
 <link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-confirm-revert-dialog">
   <template>
@@ -37,39 +38,72 @@
         display: block;
         width: 100%;
       }
+      .revertSubmissionLayout {
+        display: flex;
+      }
+      .label {
+        margin-left: var(--spacing-m);
+        margin-bottom: var(--spacing-m);
+      }
       iron-autogrow-textarea {
         font-family: var(--monospace-font-family);
         font-size: var(--font-size-mono);
         line-height: var(--line-height-mono);
-        padding: 0;
         width: 73ch; /* Add a char to account for the border. */
-
-        --iron-autogrow-textarea {
-          border: 1px solid var(--border-color);
-          box-sizing: border-box;
-          font-family: var(--monospace-font-family);
-        }
+      }
+      .error {
+        color: var(--error-text-color);
+        margin-bottom: var(--spacing-m);
       }
     </style>
     <gr-dialog
         confirm-label="Revert"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header" slot="header">Revert Merged Change</div>
+      <div class="header" slot="header">
+        Revert Merged Change
+      </div>
       <div class="main" slot="main">
+        <div class="error" hidden$="[[!_showErrorMessage]]">
+          <span> A reason is required </span>
+        </div>
+        <template is="dom-if" if="[[_showRevertSubmission]]">
+          <div class="revertSubmissionLayout">
+            <input
+              name="revertOptions"
+              type="radio"
+              id="revertSingleChange"
+              on-change="_handleRevertSingleChangeClicked"
+              checked="[[_computeIfSingleRevert(_revertType)]]">
+            <label for="revertSingleChange" class="label revertSingleChange">
+              Revert single change
+            </label>
+          </div>
+          <div class="revertSubmissionLayout">
+            <input
+              name="revertOptions"
+              type="radio"
+              id="revertSubmission"
+              on-change="_handleRevertSubmissionClicked"
+              checked="[[_computeIfRevertSubmission(_revertType)]]">
+            <label for="revertSubmission" class="label revertSubmission">
+              Revert entire submission ([[_changesCount]] Changes)
+            </label>
+        </template>
         <gr-endpoint-decorator name="confirm-revert-change">
           <label for="messageInput">
             Revert Commit Message
           </label>
           <iron-autogrow-textarea
-              id="messageInput"
-              class="message"
-              autocomplete="on"
-              max-rows="15"
-              bind-value="{{message}}"></iron-autogrow-textarea>
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            max-rows="15"
+            bind-value="{{_message}}"></iron-autogrow-textarea>
         </gr-endpoint-decorator>
       </div>
     </gr-dialog>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
   </template>
   <script src="gr-confirm-revert-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 662b64c..05660bf 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -19,10 +19,24 @@
 
   const ERR_COMMIT_NOT_FOUND =
       'Unable to find the commit hash of this change.';
+  const CHANGE_SUBJECT_LIMIT = 50;
 
-  Polymer({
-    is: 'gr-confirm-revert-dialog',
+  // TODO(dhruvsri): clean up repeated definitions after moving to js modules
+  const REVERT_TYPES = {
+    REVERT_SINGLE_CHANGE: 1,
+    REVERT_SUBMISSION: 2,
+  };
 
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmRevertDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-revert-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -35,17 +49,63 @@
      * @event cancel
      */
 
-    properties: {
-      message: String,
-    },
+    static get properties() {
+      return {
+        /* The revert message updated by the user
+        The default value is set by the dialog */
+        _message: String,
+        _revertType: {
+          type: Number,
+          value: REVERT_TYPES.REVERT_SINGLE_CHANGE,
+        },
+        _showRevertSubmission: {
+          type: Boolean,
+          value: false,
+        },
+        _changesCount: Number,
+        _showErrorMessage: {
+          type: Boolean,
+          value: false,
+        },
+        /* store the default revert messages per revert type so that we can
+        check if user has edited the revert message or not
+        Set when populate() is called */
+        _originalRevertMessages: {
+          type: Array,
+          value() { return []; },
+        },
+        // Store the actual messages that the user has edited
+        _revertMessages: {
+          type: Array,
+          value() { return []; },
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    _computeIfSingleRevert(revertType) {
+      return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    }
 
-    populateRevertMessage(message, commitHash) {
+    _computeIfRevertSubmission(revertType) {
+      return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+    }
+
+    _modifyRevertMsg(change, commitMessage, message) {
+      return this.$.jsAPI.modifyRevertMsg(change,
+          message, commitMessage);
+    }
+
+    populate(change, commitMessage, changes) {
+      this._changesCount = changes.length;
+      // The option to revert a single change is always available
+      this._populateRevertSingleChangeMessage(
+          change, commitMessage, change.current_revision);
+      this._populateRevertSubmissionMessage(change, changes, commitMessage);
+    }
+
+    _populateRevertSingleChangeMessage(change, commitMessage, commitHash) {
       // Figure out what the revert title should be.
-      const originalTitle = message.split('\n')[0];
+      const originalTitle = (commitMessage || '').split('\n')[0];
       const revertTitle = `Revert "${originalTitle}"`;
       if (!commitHash) {
         this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
@@ -53,20 +113,85 @@
       }
       const revertCommitText = `This reverts commit ${commitHash}.`;
 
-      this.message = `${revertTitle}\n\n${revertCommitText}\n\n` +
+      this._message = `${revertTitle}\n\n${revertCommitText}\n\n` +
           `Reason for revert: <INSERT REASONING HERE>\n`;
-    },
+      // This is to give plugins a chance to update message
+      this._message = this._modifyRevertMsg(change, commitMessage,
+          this._message);
+      this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+      this._showRevertSubmission = false;
+      this._revertMessages[this._revertType] = this._message;
+      this._originalRevertMessages[this._revertType] = this._message;
+    }
+
+    _getTrimmedChangeSubject(subject) {
+      if (!subject) return '';
+      if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+      return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+    }
+
+    _modifyRevertSubmissionMsg(change, msg, commitMessage) {
+      return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg,
+          commitMessage);
+    }
+
+    _populateRevertSubmissionMessage(change, changes, commitMessage) {
+      // Follow the same convention of the revert
+      const commitHash = change.current_revision;
+      if (!commitHash) {
+        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
+        return;
+      }
+      if (!changes || changes.length <= 1) return;
+      const submissionId = change.submission_id;
+      const revertTitle = 'Revert submission ' + submissionId;
+      this._message = revertTitle + '\n\n' + 'Reason for revert: <INSERT ' +
+        'REASONING HERE>\n';
+      this._message += 'Reverted Changes:\n';
+      changes.forEach(change => {
+        this._message += change.change_id.substring(0, 10) + ':'
+          + this._getTrimmedChangeSubject(change.subject) + '\n';
+      });
+      this._message = this._modifyRevertSubmissionMsg(change, this._message,
+          commitMessage);
+      this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+      this._revertMessages[this._revertType] = this._message;
+      this._originalRevertMessages[this._revertType] = this._message;
+      this._showRevertSubmission = true;
+    }
+
+    _handleRevertSingleChangeClicked() {
+      this._showErrorMessage = false;
+      this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
+      this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
+      this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    }
+
+    _handleRevertSubmissionClicked() {
+      this._showErrorMessage = false;
+      this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+      this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
+      this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
-      this.fire('confirm', null, {bubbles: false});
-    },
+      if (this._message === this._originalRevertMessages[this._revertType]) {
+        this._showErrorMessage = true;
+        return;
+      }
+      this.fire('confirm', {revertType: this._revertType,
+        message: this._message}, {bubbles: false});
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
-      this.fire('cancel', null, {bubbles: false});
-    },
-  });
+      this.fire('cancel', {revertType: this._revertType},
+          {bubbles: false});
+    }
+  }
+
+  customElements.define(GrConfirmRevertDialog.is, GrConfirmRevertDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index 6e41555..76f5ecd 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-revert-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -47,55 +47,56 @@
     teardown(() => sandbox.restore());
 
     test('no match', () => {
-      assert.isNotOk(element.message);
+      assert.isNotOk(element._message);
       const alertStub = sandbox.stub();
       element.addEventListener('show-alert', alertStub);
-      element.populateRevertMessage('not a commitHash in sight', undefined);
+      element._populateRevertSingleChangeMessage({},
+          'not a commitHash in sight', undefined);
       assert.isTrue(alertStub.calledOnce);
     });
 
     test('single line', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
+      assert.isNotOk(element._message);
+      element._populateRevertSingleChangeMessage({},
           'one line commit\n\nChange-Id: abcdefg\n',
           'abcd123');
       const expected = 'Revert "one line commit"\n\n' +
           'This reverts commit abcd123.\n\n' +
           'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
+      assert.equal(element._message, expected);
     });
 
     test('multi line', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
+      assert.isNotOk(element._message);
+      element._populateRevertSingleChangeMessage({},
           'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
           'abcd123');
       const expected = 'Revert "many lines"\n\n' +
           'This reverts commit abcd123.\n\n' +
           'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
+      assert.equal(element._message, expected);
     });
 
     test('issue above change id', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
+      assert.isNotOk(element._message);
+      element._populateRevertSingleChangeMessage({},
           'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
           'abcd123');
       const expected = 'Revert "much lines"\n\n' +
           'This reverts commit abcd123.\n\n' +
           'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
+      assert.equal(element._message, expected);
     });
 
     test('revert a revert', () => {
-      assert.isNotOk(element.message);
-      element.populateRevertMessage(
+      assert.isNotOk(element._message);
+      element._populateRevertSingleChangeMessage({},
           'Revert "one line commit"\n\nChange-Id: abcdefg\n',
           'abcd123');
       const expected = 'Revert "Revert "one line commit""\n\n' +
           'This reverts commit abcd123.\n\n' +
           'Reason for revert: <INSERT REASONING HERE>\n';
-      assert.equal(element.message, expected);
+      assert.equal(element._message, expected);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html
new file mode 100644
index 0000000..f2cfef8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.html
@@ -0,0 +1,68 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="/bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-confirm-revert-submission-dialog">
+  <template>
+    <!-- TODO(taoalpha): move all shared styles to a style module. -->
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        width: 73ch; /* Add a char to account for the border. */
+      }
+    </style>
+    <gr-dialog
+        confirm-label="Revert Submission"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header" slot="header">Revert Submission</div>
+      <div class="main" slot="main">
+        <label for="messageInput">
+          Revert Commit Message
+        </label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            max-rows="15"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-dialog>
+    <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  </template>
+  <script src="gr-confirm-revert-submission-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
new file mode 100644
index 0000000..ae8dfa5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+
+  const ERR_COMMIT_NOT_FOUND =
+      'Unable to find the commit hash of this change.';
+  const CHANGE_SUBJECT_LIMIT = 50;
+
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmRevertSubmissionDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-revert-submission-dialog'; }
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    static get properties() {
+      return {
+        message: String,
+        commitMessage: String,
+      };
+    }
+
+    _getTrimmedChangeSubject(subject) {
+      if (!subject) return '';
+      if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+      return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+    }
+
+    _modifyRevertSubmissionMsg(change) {
+      return this.$.jsAPI.modifyRevertSubmissionMsg(change,
+          this.message, this.commitMessage);
+    }
+
+    _populateRevertSubmissionMessage(message, change, changes) {
+      // Follow the same convention of the revert
+      const commitHash = change.current_revision;
+      if (!commitHash) {
+        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
+        return;
+      }
+      const submissionId = change.submission_id;
+      const revertTitle = 'Revert submission ' + submissionId;
+      this.changes = changes;
+      this.message = revertTitle + '\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
+      this.message += 'Reverted Changes:\n';
+      changes = changes || [];
+      changes.forEach(change => {
+        this.message += change.change_id.substring(0, 10) + ': ' +
+          this._getTrimmedChangeSubject(change.subject) + '\n';
+      });
+      this.message = this._modifyRevertSubmissionMsg(change);
+    }
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('confirm', null, {bubbles: false});
+    }
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('cancel', null, {bubbles: false});
+    }
+  }
+
+  customElements.define(GrConfirmRevertSubmissionDialog.is,
+      GrConfirmRevertSubmissionDialog);
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
new file mode 100644
index 0000000..af99c7e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-revert-submission-dialog</title>
+
+<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
+
+<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<script src="/bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-confirm-revert-submission-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-revert-submission-dialog>
+    </gr-confirm-revert-submission-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-revert-submission-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox =sinon.sandbox.create();
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('no match', () => {
+      assert.isNotOk(element.message);
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+      element._populateRevertSubmissionMessage(
+          'not a commitHash in sight'
+      );
+      assert.isTrue(alertStub.calledOnce);
+    });
+
+    test('single line', () => {
+      assert.isNotOk(element.message);
+      element._populateRevertSubmissionMessage(
+          'one line commit\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('multi line', () => {
+      assert.isNotOk(element.message);
+      element._populateRevertSubmissionMessage(
+          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('issue above change id', () => {
+      assert.isNotOk(element.message);
+      element._populateRevertSubmissionMessage(
+          'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+
+    test('revert a revert', () => {
+      assert.isNotOk(element.message);
+      element._populateRevertSubmissionMessage(
+          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+          'abcd123');
+      const expected = 'Revert submission\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+      assert.equal(element.message, expected);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
index e86e21c..ea8bdb5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
@@ -17,9 +17,11 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-confirm-submit-dialog',
-
+  /** @extends Polymer.Element */
+  class GrConfirmSubmitDialog extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-confirm-submit-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -32,37 +34,41 @@
      * @event cancel
      */
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * @type {{
        *    is_private: boolean,
        *    subject: string,
        *  }}
        */
-      change: Object,
+        change: Object,
 
-      /**
-       * @type {{
-       *    label: string,
-       *  }}
-       */
-      action: Object,
-    },
+        /**
+         * @type {{
+         *    label: string,
+         *  }}
+         */
+        action: Object,
+      };
+    }
 
     resetFocus(e) {
       this.$.dialog.resetFocus();
-    },
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmSubmitDialog.is, GrConfirmSubmitDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
index 40fa29a..515147f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-confirm-submit-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index e20bbd7..4ddc876 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -27,16 +27,13 @@
   <template>
     <style include="shared-styles">
       :host {
-        background-color: var(--dialog-background-color);
         display: block;
+        padding: var(--spacing-m) 0;
       }
       section {
         display: flex;
         padding: var(--spacing-m) var(--spacing-xl);
       }
-      section:not(:first-of-type) {
-        border-top: 1px solid var(--border-color);
-      }
       .flexContainer {
         display: flex;
         justify-content: space-between;
@@ -76,9 +73,9 @@
       }
     </style>
     <section>
-      <span class="title">
+      <h3 class="title">
         Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
-      </span>
+      </h3>
     </section>
     <section class$="[[_computeShowDownloadCommands(_schemes)]]">
       <gr-download-commands
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index b297a14..17c6f50 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -17,40 +17,49 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-download-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrDownloadDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-download-dialog'; }
     /**
      * Fired when the user presses the close button.
      *
      * @event close
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {{ revisions: Array }} */
-      change: Object,
-      patchNum: String,
-      /** @type {?} */
-      config: Object,
+        change: Object,
+        patchNum: String,
+        /** @type {?} */
+        config: Object,
 
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(change, patchNum)',
-        observer: '_schemesChanged',
-      },
-      _selectedScheme: String,
-    },
+        _schemes: {
+          type: Array,
+          value() { return []; },
+          computed: '_computeSchemes(change, patchNum)',
+          observer: '_schemesChanged',
+        },
+        _selectedScheme: String,
+      };
+    }
 
-    hostAttributes: {
-      role: 'dialog',
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('role', 'dialog');
+    }
 
     focus() {
       if (this._schemes.length) {
@@ -58,15 +67,16 @@
       } else {
         this.$.download.focus();
       }
-    },
+    }
 
     getFocusStops() {
-      const links = this.$$('#archives').querySelectorAll('a');
+      const links = this.shadowRoot
+          .querySelector('#archives').querySelectorAll('a');
       return {
         start: this.$.closeButton,
         end: links[links.length - 1],
       };
-    },
+    }
 
     _computeDownloadCommands(change, patchNum, _selectedScheme) {
       let commandObj;
@@ -87,7 +97,7 @@
         });
       }
       return commands;
-    },
+    }
 
     /**
      * @param {!Object} change
@@ -97,7 +107,7 @@
      */
     _computeZipDownloadLink(change, patchNum) {
       return this._computeDownloadLink(change, patchNum, true);
-    },
+    }
 
     /**
      * @param {!Object} change
@@ -107,7 +117,7 @@
      */
     _computeZipDownloadFilename(change, patchNum) {
       return this._computeDownloadFilename(change, patchNum, true);
-    },
+    }
 
     /**
      * @param {!Object} change
@@ -123,8 +133,7 @@
       }
       return this.changeBaseURL(change.project, change._number, patchNum) +
           '/patch?' + (opt_zip ? 'zip' : 'download');
-    },
-
+    }
 
     /**
      * @param {!Object} change
@@ -147,7 +156,7 @@
         }
       }
       return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
-    },
+    }
 
     _computeHidePatchFile(change, patchNum) {
       // Polymer 2: check for undefined
@@ -162,7 +171,7 @@
         }
       }
       return false;
-    },
+    }
 
     _computeArchiveDownloadLink(change, patchNum, format) {
       // Polymer 2: check for undefined
@@ -171,7 +180,7 @@
       }
       return this.changeBaseURL(change.project, change._number, patchNum) +
           '/archive?format=' + format;
-    },
+    }
 
     _computeSchemes(change, patchNum) {
       // Polymer 2: check for undefined
@@ -189,28 +198,30 @@
         }
       }
       return [];
-    },
+    }
 
     _computePatchSetQuantity(revisions) {
       if (!revisions) { return 0; }
       return Object.keys(revisions).length;
-    },
+    }
 
     _handleCloseTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('close', null, {bubbles: false});
-    },
+    }
 
     _schemesChanged(schemes) {
       if (schemes.length === 0) { return; }
       if (!schemes.includes(this._selectedScheme)) {
         this._selectedScheme = schemes.sort()[0];
       }
-    },
+    }
 
     _computeShowDownloadCommands(schemes) {
       return schemes.length ? '' : 'hidden';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDownloadDialog.is, GrDownloadDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 82574808..0f1260f 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index d6fbfc4..5828006 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -21,9 +21,18 @@
   const PATCH_DESC_MAX_LENGTH = 500;
   const MERGED_STATUS = 'MERGED';
 
-  Polymer({
-    is: 'gr-file-list-header',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @extends Polymer.Element
+   */
+  class GrFileListHeader extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-file-list-header'; }
     /**
      * @event expand-diffs
      */
@@ -48,72 +57,71 @@
      * @event open-upload-help-dialog
      */
 
-    properties: {
-      account: Object,
-      allPatchSets: Array,
-      /** @type {?} */
-      change: Object,
-      changeNum: String,
-      changeUrl: String,
-      changeComments: Object,
-      commitInfo: Object,
-      editMode: Boolean,
-      loggedIn: Boolean,
-      serverConfig: Object,
-      shownFileCount: Number,
-      diffPrefs: Object,
-      diffPrefsDisabled: Boolean,
-      diffViewMode: {
-        type: String,
-        notify: true,
-      },
-      patchNum: String,
-      basePatchNum: String,
-      filesExpanded: String,
-      // Caps the number of files that can be shown and have the 'show diffs' /
-      // 'hide diffs' buttons still be functional.
-      _maxFilesForBulkActions: {
-        type: Number,
-        readOnly: true,
-        value: 225,
-      },
-      _patchsetDescription: {
-        type: String,
-        value: '',
-      },
-      showTitle: {
-        type: Boolean,
-        value: true,
-      },
-      _descriptionReadOnly: {
-        type: Boolean,
-        computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
-      },
-      revisionInfo: Object,
-    },
+    static get properties() {
+      return {
+        account: Object,
+        allPatchSets: Array,
+        /** @type {?} */
+        change: Object,
+        changeNum: String,
+        changeUrl: String,
+        changeComments: Object,
+        commitInfo: Object,
+        editMode: Boolean,
+        loggedIn: Boolean,
+        serverConfig: Object,
+        shownFileCount: Number,
+        diffPrefs: Object,
+        diffPrefsDisabled: Boolean,
+        diffViewMode: {
+          type: String,
+          notify: true,
+        },
+        patchNum: String,
+        basePatchNum: String,
+        filesExpanded: String,
+        // Caps the number of files that can be shown and have the 'show diffs' /
+        // 'hide diffs' buttons still be functional.
+        _maxFilesForBulkActions: {
+          type: Number,
+          readOnly: true,
+          value: 225,
+        },
+        _patchsetDescription: {
+          type: String,
+          value: '',
+        },
+        showTitle: {
+          type: Boolean,
+          value: true,
+        },
+        _descriptionReadOnly: {
+          type: Boolean,
+          computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
+        },
+        revisionInfo: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-    ],
-
-    observers: [
-      '_computePatchSetDescription(change, patchNum)',
-    ],
+    static get observers() {
+      return [
+        '_computePatchSetDescription(change, patchNum)',
+      ];
+    }
 
     setDiffViewMode(mode) {
       this.$.modeSelect.setMode(mode);
-    },
+    }
 
     _expandAllDiffs() {
       this._expanded = true;
       this.fire('expand-diffs');
-    },
+    }
 
     _collapseAllDiffs() {
       this._expanded = false;
       this.fire('collapse-diffs');
-    },
+    }
 
     _computeExpandedClass(filesExpanded) {
       const classes = [];
@@ -125,11 +133,11 @@
         classes.push('openFile');
       }
       return classes.join(' ');
-    },
+    }
 
     _computeDescriptionPlaceholder(readOnly) {
       return (readOnly ? 'No' : 'Add') + ' patchset description';
-    },
+    }
 
     _computeDescriptionReadOnly(loggedIn, change, account) {
       // Polymer 2: check for undefined
@@ -138,7 +146,7 @@
       }
 
       return !(loggedIn && (account._account_id === change.owner._account_id));
-    },
+    }
 
     _computePatchSetDescription(change, patchNum) {
       // Polymer 2: check for undefined
@@ -149,11 +157,11 @@
       const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
       this._patchsetDescription = (rev && rev.description) ?
         rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    },
+    }
 
     _handleDescriptionRemoved(e) {
       return this._updateDescription('', e);
-    },
+    }
 
     /**
      * @param {!Object} revisions The revisions object keyed by revision hashes
@@ -167,12 +175,12 @@
           return rev;
         }
       }
-    },
+    }
 
     _handleDescriptionChanged(e) {
       const desc = e.detail.trim();
       this._updateDescription(desc, e);
-    },
+    }
 
     /**
      * Update the patchset description with the rest API.
@@ -194,47 +202,48 @@
               this.set(['change', 'revisions', sha, 'description'], desc);
               this._patchsetDescription = desc;
             }
-          }).catch(err => {
+          })
+          .catch(err => {
             if (target) { target.disabled = false; }
             return;
           });
-    },
+    }
 
     _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
       return diffPrefsDisabled || !prefs;
-    },
+    }
 
     _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
       return shownFileCount <= maxFilesForBulkActions;
-    },
+    }
 
     _handlePatchChange(e) {
       const {basePatchNum, patchNum} = e.detail;
       if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
           this.patchNumEquals(patchNum, this.patchNum)) { return; }
       Gerrit.Nav.navigateToChange(this.change, patchNum, basePatchNum);
-    },
+    }
 
     _handlePrefsTap(e) {
       e.preventDefault();
       this.fire('open-diff-prefs');
-    },
+    }
 
     _handleIncludedInTap(e) {
       e.preventDefault();
       this.fire('open-included-in-dialog');
-    },
+    }
 
     _handleDownloadTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.dispatchEvent(
           new CustomEvent('open-download-dialog', {bubbles: false}));
-    },
+    }
 
     _computeEditModeClass(editMode) {
       return editMode ? 'editMode' : '';
-    },
+    }
 
     _computePatchInfoClass(patchNum, allPatchSets) {
       const latestNum = this.computeLatestPatchNum(allPatchSets);
@@ -242,18 +251,18 @@
         return '';
       }
       return 'patchInfoOldPatchSet';
-    },
+    }
 
     _hideIncludedIn(change) {
       return change && change.status === MERGED_STATUS ? '' : 'hide';
-    },
+    }
 
     _handleUploadTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.dispatchEvent(
           new CustomEvent('open-upload-help-dialog', {bubbles: false}));
-    },
+    }
 
     _computeUploadHelpContainerClass(change, account) {
       const changeIsMerged = change && change.status === MERGED_STATUS;
@@ -263,6 +272,8 @@
       const userIsOwner = ownerId && userId && ownerId === userId;
       const hideContainer = !userIsOwner || changeIsMerged;
       return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
-    },
-  });
+    }
+  }
+
+  customElements.define(GrFileListHeader.is, GrFileListHeader);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index ac626ab..1b4b34d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list-header</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -133,35 +133,37 @@
 
       // Simulate tapping the remove button, but call function directly so that
       // can determine what happens after the promise is resolved.
-      return element._handleDescriptionRemoved().then(() => {
-        // The API stub should be called with an empty string for the new
-        // description.
-        assert.equal(putDescStub.lastCall.args[2], '');
-        assert.equal(element.change.revisions.rev1.description, '');
+      return element._handleDescriptionRemoved()
+          .then(() => {
+            // The API stub should be called with an empty string for the new
+            // description.
+            assert.equal(putDescStub.lastCall.args[2], '');
+            assert.equal(element.change.revisions.rev1.description, '');
 
-        flushAsynchronousOperations();
-        // The editable label should now be visible and the chip hidden.
-        label = Polymer.dom(element.root).querySelector('#descriptionLabel');
-        assert.isOk(label);
-        assert.equal(getComputedStyle(chip).display, 'none');
-        assert.notEqual(getComputedStyle(label).display, 'none');
-        assert.isFalse(label.readOnly);
-        // Edit the label to have a new value of test2, and save.
-        label.editing = true;
-        label._inputText = 'test2';
-        label._save();
-        flushAsynchronousOperations();
-        // The API stub should be called with an `test2` for the new
-        // description.
-        assert.equal(putDescStub.callCount, 2);
-        assert.equal(putDescStub.lastCall.args[2], 'test2');
-      }).then(() => {
-        flushAsynchronousOperations();
-        // The chip should be visible again, and the label hidden.
-        assert.equal(element.change.revisions.rev1.description, 'test2');
-        assert.equal(getComputedStyle(label).display, 'none');
-        assert.notEqual(getComputedStyle(chip).display, 'none');
-      });
+            flushAsynchronousOperations();
+            // The editable label should now be visible and the chip hidden.
+            label = Polymer.dom(element.root).querySelector('#descriptionLabel');
+            assert.isOk(label);
+            assert.equal(getComputedStyle(chip).display, 'none');
+            assert.notEqual(getComputedStyle(label).display, 'none');
+            assert.isFalse(label.readOnly);
+            // Edit the label to have a new value of test2, and save.
+            label.editing = true;
+            label._inputText = 'test2';
+            label._save();
+            flushAsynchronousOperations();
+            // The API stub should be called with an `test2` for the new
+            // description.
+            assert.equal(putDescStub.callCount, 2);
+            assert.equal(putDescStub.lastCall.args[2], 'test2');
+          })
+          .then(() => {
+            flushAsynchronousOperations();
+            // The chip should be visible again, and the label hidden.
+            assert.equal(element.change.revisions.rev1.description, 'test2');
+            assert.equal(getComputedStyle(label).display, 'none');
+            assert.notEqual(getComputedStyle(chip).display, 'none');
+          });
     });
 
     test('expandAllDiffs called when expand button clicked', () => {
@@ -216,8 +218,8 @@
     test('expand/collapse buttons are toggled correctly', () => {
       element.shownFileCount = 10;
       flushAsynchronousOperations();
-      const expandBtn = element.$$('#expandBtn');
-      const collapseBtn = element.$$('#collapseBtn');
+      const expandBtn = element.shadowRoot.querySelector('#expandBtn');
+      const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
       assert.notEqual(getComputedStyle(expandBtn).display, 'none');
       assert.equal(getComputedStyle(collapseBtn).display, 'none');
       element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 649aa53..0a7f1ce 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -434,7 +434,6 @@
                 no-auto-render
                 show-load-failure
                 display-line="[[_displayLine]]"
-                inline-index=[[index]]
                 hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
                 change-num="[[changeNum]]"
                 patch-range="[[patchRange]]"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 8d9b905..0bc1a22 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -41,162 +41,176 @@
     U: 'Unchanged',
   };
 
-  Polymer({
-    is: 'gr-file-list',
-
+  /**
+   * @appliesMixin Gerrit.AsyncForeachMixin
+   * @appliesMixin Gerrit.DomUtilMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @extends Polymer.Element
+   */
+  class GrFileList extends Polymer.mixinBehaviors( [
+    Gerrit.AsyncForeachBehavior,
+    Gerrit.DomUtilBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.PathListBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-file-list'; }
     /**
      * Fired when a draft refresh should get triggered
      *
      * @event reload-drafts
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      patchRange: Object,
-      patchNum: String,
-      changeNum: String,
-      /** @type {?} */
-      changeComments: Object,
-      drafts: Object,
-      revisions: Array,
-      projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /** @type {?} */
-      change: Object,
-      diffViewMode: {
-        type: String,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      editMode: {
-        type: Boolean,
-        observer: '_editModeChanged',
-      },
-      filesExpanded: {
-        type: String,
-        value: GrFileListConstants.FilesExpandedState.NONE,
-        notify: true,
-      },
-      _filesByPath: Object,
-      _files: {
-        type: Array,
-        observer: '_filesChanged',
-        value() { return []; },
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _reviewed: {
-        type: Array,
-        value() { return []; },
-      },
-      diffPrefs: {
-        type: Object,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      /** @type {?} */
-      _userPrefs: Object,
-      _showInlineDiffs: Boolean,
-      numFilesShown: {
-        type: Number,
-        notify: true,
-      },
-      /** @type {?} */
-      _patchChange: {
-        type: Object,
-        computed: '_calculatePatchChange(_files)',
-      },
-      fileListIncrement: Number,
-      _hideChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideChangeTotals(_patchChange)',
-      },
-      _hideBinaryChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-      },
+        patchRange: Object,
+        patchNum: String,
+        changeNum: String,
+        /** @type {?} */
+        changeComments: Object,
+        drafts: Object,
+        revisions: Array,
+        projectConfig: Object,
+        selectedIndex: {
+          type: Number,
+          notify: true,
+        },
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
+        },
+        /** @type {?} */
+        change: Object,
+        diffViewMode: {
+          type: String,
+          notify: true,
+          observer: '_updateDiffPreferences',
+        },
+        editMode: {
+          type: Boolean,
+          observer: '_editModeChanged',
+        },
+        filesExpanded: {
+          type: String,
+          value: GrFileListConstants.FilesExpandedState.NONE,
+          notify: true,
+        },
+        _filesByPath: Object,
+        _files: {
+          type: Array,
+          observer: '_filesChanged',
+          value() { return []; },
+        },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        _reviewed: {
+          type: Array,
+          value() { return []; },
+        },
+        diffPrefs: {
+          type: Object,
+          notify: true,
+          observer: '_updateDiffPreferences',
+        },
+        /** @type {?} */
+        _userPrefs: Object,
+        _showInlineDiffs: Boolean,
+        numFilesShown: {
+          type: Number,
+          notify: true,
+        },
+        /** @type {?} */
+        _patchChange: {
+          type: Object,
+          computed: '_calculatePatchChange(_files)',
+        },
+        fileListIncrement: Number,
+        _hideChangeTotals: {
+          type: Boolean,
+          computed: '_shouldHideChangeTotals(_patchChange)',
+        },
+        _hideBinaryChangeTotals: {
+          type: Boolean,
+          computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+        },
 
-      _shownFiles: {
-        type: Array,
-        computed: '_computeFilesShown(numFilesShown, _files)',
-      },
+        _shownFiles: {
+          type: Array,
+          computed: '_computeFilesShown(numFilesShown, _files)',
+        },
 
-      /**
-       * The amount of files added to the shown files list the last time it was
-       * updated. This is used for reporting the average render time.
-       */
-      _reportinShownFilesIncrement: Number,
+        /**
+         * The amount of files added to the shown files list the last time it was
+         * updated. This is used for reporting the average render time.
+         */
+        _reportinShownFilesIncrement: Number,
 
-      _expandedFilePaths: {
-        type: Array,
-        value() { return []; },
-      },
-      _displayLine: Boolean,
-      _loading: {
-        type: Boolean,
-        observer: '_loadingChanged',
-      },
-      /** @type {Gerrit.LayoutStats|undefined} */
-      _sizeBarLayout: {
-        type: Object,
-        computed: '_computeSizeBarLayout(_shownFiles.*)',
-      },
+        _expandedFilePaths: {
+          type: Array,
+          value() { return []; },
+        },
+        _displayLine: Boolean,
+        _loading: {
+          type: Boolean,
+          observer: '_loadingChanged',
+        },
+        /** @type {Gerrit.LayoutStats|undefined} */
+        _sizeBarLayout: {
+          type: Object,
+          computed: '_computeSizeBarLayout(_shownFiles.*)',
+        },
 
-      _showSizeBars: {
-        type: Boolean,
-        value: true,
-        computed: '_computeShowSizeBars(_userPrefs)',
-      },
+        _showSizeBars: {
+          type: Boolean,
+          value: true,
+          computed: '_computeShowSizeBars(_userPrefs)',
+        },
 
-      /** @type {Function} */
-      _cancelForEachDiff: Function,
+        /** @type {Function} */
+        _cancelForEachDiff: Function,
 
-      _showDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+        _showDynamicColumns: {
+          type: Boolean,
+          computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
                   '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-      },
-      /** @type {Array<string>} */
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicContentEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicSummaryEndpoints: {
-        type: Array,
-      },
-    },
+        },
+        /** @type {Array<string>} */
+        _dynamicHeaderEndpoints: {
+          type: Array,
+        },
+        /** @type {Array<string>} */
+        _dynamicContentEndpoints: {
+          type: Array,
+        },
+        /** @type {Array<string>} */
+        _dynamicSummaryEndpoints: {
+          type: Array,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.AsyncForeachBehavior,
-      Gerrit.DomUtilBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.PathListBehavior,
-    ],
-
-    observers: [
-      '_expandedPathsChanged(_expandedFilePaths.splices)',
-      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
+    static get observers() {
+      return [
+        '_expandedPathsChanged(_expandedFilePaths.splices)',
+        '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
           '_loading)',
-    ],
+      ];
+    }
 
-    keyBindings: {
-      esc: '_handleEscKey',
-    },
+    get keyBindings() {
+      return {
+        esc: '_handleEscKey',
+      };
+    }
 
     keyboardShortcuts() {
       return {
@@ -221,12 +235,18 @@
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
         [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
       };
-    },
-    listeners: {
-      keydown: '_scopedKeydownHandler',
-    },
+    }
 
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('keydown',
+          e => this._scopedKeydownHandler(e));
+    }
+
+    /** @override */
     attached() {
+      super.attached();
       Gerrit.awaitPluginsLoaded().then(() => {
         this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
             'change-view-file-list-header');
@@ -246,11 +266,13 @@
               'Different number of dynamic file-list headers and summary.');
         }
       });
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this._cancelDiffs();
-    },
+    }
 
     /**
      * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
@@ -264,7 +286,7 @@
         // Enter.
         this._handleOpenFile(e);
       }
-    },
+    }
 
     reload() {
       if (!this.changeNum || !this.patchRange.patchNum) {
@@ -279,15 +301,15 @@
       promises.push(this._getFiles().then(filesByPath => {
         this._filesByPath = filesByPath;
       }));
-      promises.push(this._getLoggedIn().then(loggedIn => {
-        return this._loggedIn = loggedIn;
-      }).then(loggedIn => {
-        if (!loggedIn) { return; }
+      promises.push(this._getLoggedIn()
+          .then(loggedIn => this._loggedIn = loggedIn)
+          .then(loggedIn => {
+            if (!loggedIn) { return; }
 
-        return this._getReviewedFiles().then(reviewed => {
-          this._reviewed = reviewed;
-        });
-      }));
+            return this._getReviewedFiles().then(reviewed => {
+              this._reviewed = reviewed;
+            });
+          }));
 
       promises.push(this._getDiffPreferences().then(prefs => {
         this.diffPrefs = prefs;
@@ -302,28 +324,28 @@
         this._detectChromiteButler();
         this.$.reporting.fileListDisplayed();
       });
-    },
+    }
 
     _detectChromiteButler() {
       const hasButler = !!document.getElementById('butler-suggested-owners');
       if (hasButler) {
         this.$.reporting.reportExtension('butler');
       }
-    },
+    }
 
     get diffs() {
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('gr-diff-host'));
-    },
+    }
 
     openDiffPrefs() {
       this.$.diffPreferencesDialog.open();
-    },
+    }
 
     _calculatePatchChange(files) {
-      const magicFilesExcluded = files.filter(files => {
-        return files.__path !== '/COMMIT_MSG' && files.__path !== '/MERGE_LIST';
-      });
+      const magicFilesExcluded = files.filter(files =>
+        !this.isMagicPath(files.__path)
+      );
 
       return magicFilesExcluded.reduce((acc, obj) => {
         const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
@@ -343,15 +365,15 @@
         };
       }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
         size_delta_deleted: 0, total_size: 0});
-    },
+    }
 
     _getDiffPreferences() {
       return this.$.restAPI.getDiffPreferences();
-    },
+    }
 
     _getPreferences() {
       return this.$.restAPI.getPreferences();
-    },
+    }
 
     _togglePathExpanded(path) {
       // Is the path in the list of expanded diffs? IF so remove it, otherwise
@@ -362,11 +384,11 @@
       } else {
         this.splice('_expandedFilePaths', pathIndex, 1);
       }
-    },
+    }
 
     _togglePathExpandedByIndex(index) {
       this._togglePathExpanded(this._files[index].__path);
-    },
+    }
 
     _updateDiffPreferences() {
       if (!this.diffs.length) { return; }
@@ -374,14 +396,14 @@
       this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
       this._renderInOrder(this._expandedFilePaths, this.diffs,
           this._expandedFilePaths.length);
-    },
+    }
 
     _forEachDiff(fn) {
       const diffs = this.diffs;
       for (let i = 0; i < diffs.length; i++) {
         fn(diffs[i]);
       }
-    },
+    }
 
     expandAllDiffs() {
       this._showInlineDiffs = true;
@@ -398,7 +420,7 @@
       }
 
       this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
-    },
+    }
 
     collapseAllDiffs() {
       this._showInlineDiffs = false;
@@ -406,7 +428,7 @@
       this.filesExpanded = this._computeExpandedFiles(
           this._expandedFilePaths.length, this._files.length);
       this.$.diffCursor.handleDiffUpdate();
-    },
+    }
 
     /**
      * Computes a string with the number of comments and unresolved comments.
@@ -433,7 +455,7 @@
           (commentString && unresolvedString ? ' ' : '') +
           // Add parentheses around unresolved if it exists.
           (unresolvedString ? `(${unresolvedString})` : '');
-    },
+    }
 
     /**
      * Computes a string with the number of drafts.
@@ -448,7 +470,7 @@
           changeComments.computeDraftCount(patchRange.basePatchNum, path) +
           changeComments.computeDraftCount(patchRange.patchNum, path);
       return GrCountStringFormatter.computePluralString(draftCount, 'draft');
-    },
+    }
 
     /**
      * Computes a shortened string with the number of drafts.
@@ -463,7 +485,7 @@
           changeComments.computeDraftCount(patchRange.basePatchNum, path) +
           changeComments.computeDraftCount(patchRange.patchNum, path);
       return GrCountStringFormatter.computeShortString(draftCount, 'd');
-    },
+    }
 
     /**
      * Computes a shortened string with the number of comments.
@@ -478,7 +500,7 @@
           changeComments.computeCommentCount(patchRange.basePatchNum, path) +
           changeComments.computeCommentCount(patchRange.patchNum, path);
       return GrCountStringFormatter.computeShortString(commentCount, 'c');
-    },
+    }
 
     /**
      * @param {string} path
@@ -495,27 +517,27 @@
       }
 
       this._saveReviewedState(path, reviewed);
-    },
+    }
 
     _saveReviewedState(path, reviewed) {
       return this.$.restAPI.saveFileReviewed(this.changeNum,
           this.patchRange.patchNum, path, reviewed);
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _getReviewedFiles() {
       if (this.editMode) { return Promise.resolve([]); }
       return this.$.restAPI.getReviewedFiles(this.changeNum,
           this.patchRange.patchNum);
-    },
+    }
 
     _getFiles() {
       return this.$.restAPI.getChangeOrEditFiles(
           this.changeNum, this.patchRange);
-    },
+    }
 
     /**
      * The closure compiler doesn't realize this.specialFilePathCompare is
@@ -535,7 +557,7 @@
         files.push(info);
       }
       return files;
-    },
+    }
 
     /**
      * Handle all events from the file list dom-repeat so event handleers don't
@@ -565,7 +587,7 @@
 
       e.preventDefault();
       this._togglePathExpanded(path);
-    },
+    }
 
     _handleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
@@ -574,7 +596,7 @@
 
       e.preventDefault();
       this.$.diffCursor.moveLeft();
-    },
+    }
 
     _handleRightPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
@@ -583,7 +605,7 @@
 
       e.preventDefault();
       this.$.diffCursor.moveRight();
-    },
+    }
 
     _handleToggleInlineDiff(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -592,14 +614,14 @@
 
       e.preventDefault();
       this._togglePathExpandedByIndex(this.$.fileCursor.index);
-    },
+    }
 
     _handleToggleAllInlineDiffs(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._toggleInlineDiffs();
-    },
+    }
 
     _handleCursorNext(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
@@ -617,7 +639,7 @@
         this.$.fileCursor.next();
         this.selectedIndex = this.$.fileCursor.index;
       }
-    },
+    }
 
     _handleCursorPrev(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
@@ -635,20 +657,14 @@
         this.$.fileCursor.previous();
         this.selectedIndex = this.$.fileCursor.index;
       }
-    },
+    }
 
     _handleNewComment(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
-
-      const isRangeSelected = this.diffs.some(diff => {
-        return diff.isRangeSelected();
-      }, this);
-      if (!isRangeSelected) {
-        e.preventDefault();
-        this._addDraftAtTarget();
-      }
-    },
+      e.preventDefault();
+      this.$.diffCursor.createCommentInPlace();
+    }
 
     _handleOpenLastFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
@@ -657,7 +673,7 @@
 
       e.preventDefault();
       this._openSelectedFile(this._files.length - 1);
-    },
+    }
 
     _handleOpenFirstFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
@@ -666,7 +682,7 @@
 
       e.preventDefault();
       this._openSelectedFile(0);
-    },
+    }
 
     _handleOpenFile(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -679,7 +695,7 @@
       }
 
       this._openSelectedFile();
-    },
+    }
 
     _handleNextChunk(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -694,7 +710,7 @@
       } else {
         this.$.diffCursor.moveToNextChunk();
       }
-    },
+    }
 
     _handlePrevChunk(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -709,7 +725,7 @@
       } else {
         this.$.diffCursor.moveToPreviousChunk();
       }
-    },
+    }
 
     _handleToggleFileReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
@@ -719,7 +735,7 @@
       e.preventDefault();
       if (!this._files[this.$.fileCursor.index]) { return; }
       this._reviewFile(this._files[this.$.fileCursor.index].__path);
-    },
+    }
 
     _handleToggleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -728,7 +744,7 @@
       this._forEachDiff(diff => {
         diff.toggleLeftDiff();
       });
-    },
+    }
 
     _toggleInlineDiffs() {
       if (this._showInlineDiffs) {
@@ -736,13 +752,13 @@
       } else {
         this.expandAllDiffs();
       }
-    },
+    }
 
     _openCursorFile() {
       const diff = this.$.diffCursor.getTargetDiffElement();
       Gerrit.Nav.navigateToDiff(this.change, diff.path,
           diff.patchRange.patchNum, this.patchRange.basePatchNum);
-    },
+    }
 
     /**
      * @param {number=} opt_index
@@ -755,7 +771,7 @@
       Gerrit.Nav.navigateToDiff(this.change,
           this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
           this.patchRange.basePatchNum);
-    },
+    }
 
     _addDraftAtTarget() {
       const diff = this.$.diffCursor.getTargetDiffElement();
@@ -763,20 +779,20 @@
       if (diff && target) {
         diff.addDraftAtLine(target);
       }
-    },
+    }
 
     _shouldHideChangeTotals(_patchChange) {
       return _patchChange.inserted === 0 && _patchChange.deleted === 0;
-    },
+    }
 
     _shouldHideBinaryChangeTotals(_patchChange) {
       return _patchChange.size_delta_inserted === 0 &&
           _patchChange.size_delta_deleted === 0;
-    },
+    }
 
     _computeFileStatus(status) {
       return status || 'M';
-    },
+    }
 
     _computeDiffURL(change, patchNum, basePatchNum, path, editMode) {
       // Polymer 2: check for undefined
@@ -791,7 +807,7 @@
             basePatchNum);
       }
       return Gerrit.Nav.getUrlForDiff(change, path, patchNum, basePatchNum);
-    },
+    }
 
     _formatBytes(bytes) {
       if (bytes == 0) return '+/-0 B';
@@ -803,7 +819,7 @@
       const prepend = bytes > 0 ? '+' : '';
       return prepend + parseFloat((bytes / Math.pow(bits, exponent))
           .toFixed(decimals)) + ' ' + sizes[exponent];
-    },
+    }
 
     _formatPercentage(size, delta) {
       const oldSize = size - delta;
@@ -812,12 +828,12 @@
 
       const percentage = Math.round(Math.abs(delta * 100 / oldSize));
       return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
-    },
+    }
 
     _computeBinaryClass(delta) {
       if (delta === 0) { return; }
       return delta >= 0 ? 'added' : 'removed';
-    },
+    }
 
     /**
      * @param {string} baseClass
@@ -832,16 +848,16 @@
         classes.push('invisible');
       }
       return classes.join(' ');
-    },
+    }
 
     _computePathClass(path, expandedFilesRecord) {
       return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
-    },
+    }
 
     _computeShowHideIcon(path, expandedFilesRecord) {
       return this._isFileExpanded(path, expandedFilesRecord) ?
         'gr-icons:expand-less' : 'gr-icons:expand-more';
-    },
+    }
 
     _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
       // Polymer 2: check for undefined
@@ -871,7 +887,7 @@
       }
 
       this._files = this._normalizeChangeFilesResponse(files);
-    },
+    }
 
     _computeFilesShown(numFilesShown, files) {
       // Polymer 2: check for undefined
@@ -895,13 +911,13 @@
           Math.max(0, filesShown.length - previousNumFilesShown);
 
       return filesShown;
-    },
+    }
 
     _updateDiffCursor() {
       // Overwrite the cursor's list of diffs:
       this.$.diffCursor.splice(
           ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
-    },
+    }
 
     _filesChanged() {
       if (this._files && this._files.length > 0) {
@@ -911,41 +927,41 @@
         this.$.fileCursor.stops = files;
         this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
       }
-    },
+    }
 
     _incrementNumFilesShown() {
       this.numFilesShown += this.fileListIncrement;
-    },
+    }
 
     _computeFileListControlClass(numFilesShown, files) {
       return numFilesShown >= files.length ? 'invisible' : '';
-    },
+    }
 
     _computeIncrementText(numFilesShown, files) {
       if (!files) { return ''; }
       const text =
           Math.min(this.fileListIncrement, files.length - numFilesShown);
       return 'Show ' + text + ' more';
-    },
+    }
 
     _computeShowAllText(files) {
       if (!files) { return ''; }
       return 'Show all ' + files.length + ' files';
-    },
+    }
 
     _computeWarnShowAll(files) {
       return files.length > WARN_SHOW_ALL_THRESHOLD;
-    },
+    }
 
     _computeShowAllWarning(files) {
       if (!this._computeWarnShowAll(files)) { return ''; }
       return 'Warning: showing all ' + files.length +
           ' files may take several seconds.';
-    },
+    }
 
     _showAllFiles() {
       this.numFilesShown = this._files.length;
-    },
+    }
 
     _computePatchSetDescription(revisions, patchNum) {
       // Polymer 2: check for undefined
@@ -956,7 +972,7 @@
       const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
         rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    },
+    }
 
     /**
      * Get a descriptive label for use in the status indicator's tooltip and
@@ -969,16 +985,16 @@
       const statusCode = this._computeFileStatus(status);
       return FileStatus.hasOwnProperty(statusCode) ?
         FileStatus[statusCode] : 'Status Unknown';
-    },
+    }
 
     _isFileExpanded(path, expandedFilesRecord) {
       return expandedFilesRecord.base.includes(path);
-    },
+    }
 
     _onLineSelected(e, detail) {
       this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
           detail.path);
-    },
+    }
 
     _computeExpandedFiles(expandedCount, totalCount) {
       if (expandedCount === 0) {
@@ -987,7 +1003,7 @@
         return GrFileListConstants.FilesExpandedState.ALL;
       }
       return GrFileListConstants.FilesExpandedState.SOME;
-    },
+    }
 
     /**
      * Handle splices to the list of expanded file paths. If there are any new
@@ -1026,14 +1042,14 @@
 
       this._updateDiffCursor();
       this.$.diffCursor.handleDiffUpdate();
-    },
+    }
 
     _clearCollapsedDiffs(collapsedDiffs) {
       for (const diff of collapsedDiffs) {
         diff.cancel();
         diff.clearDiffContent();
       }
-    },
+    }
 
     /**
      * Given an array of paths and a NodeList of diff elements, render the diff
@@ -1051,37 +1067,35 @@
 
       return (new Promise(resolve => {
         this.fire('reload-drafts', {resolve});
-      })).then(() => {
-        return this.asyncForeach(paths, (path, cancel) => {
-          this._cancelForEachDiff = cancel;
+      })).then(() => this.asyncForeach(paths, (path, cancel) => {
+        this._cancelForEachDiff = cancel;
 
-          iter++;
-          console.log('Expanding diff', iter, 'of', initialCount, ':',
-              path);
-          const diffElem = this._findDiffByPath(path, diffElements);
-          diffElem.comments = this.changeComments.getCommentsBySideForPath(
-              path, this.patchRange, this.projectConfig);
-          const promises = [diffElem.reload()];
-          if (this._loggedIn && !this.diffPrefs.manual_review) {
-            promises.push(this._reviewFile(path, true));
-          }
-          return Promise.all(promises);
-        }).then(() => {
-          this._cancelForEachDiff = null;
-          this._nextRenderParams = null;
-          console.log('Finished expanding', initialCount, 'diff(s)');
-          this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
-              EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
-          this.$.diffCursor.handleDiffUpdate();
-        });
-      });
-    },
+        iter++;
+        console.log('Expanding diff', iter, 'of', initialCount, ':',
+            path);
+        const diffElem = this._findDiffByPath(path, diffElements);
+        diffElem.comments = this.changeComments.getCommentsBySideForPath(
+            path, this.patchRange, this.projectConfig);
+        const promises = [diffElem.reload()];
+        if (this._loggedIn && !this.diffPrefs.manual_review) {
+          promises.push(this._reviewFile(path, true));
+        }
+        return Promise.all(promises);
+      }).then(() => {
+        this._cancelForEachDiff = null;
+        this._nextRenderParams = null;
+        console.log('Finished expanding', initialCount, 'diff(s)');
+        this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
+            EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
+        this.$.diffCursor.handleDiffUpdate();
+      }));
+    }
 
     /** Cancel the rendering work of every diff in the list */
     _cancelDiffs() {
       if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
       this._forEachDiff(d => d.cancel());
-    },
+    }
 
     /**
      * In the given NodeList of diff elements, find the diff for the given path.
@@ -1096,7 +1110,7 @@
           return diffElements[i];
         }
       }
-    },
+    }
 
     /**
      * Reset the comments of a modified thread
@@ -1129,19 +1143,19 @@
       // comments due to use in the _handleCommentUpdate function.
       // The comment thread already has a side associated with it, so
       // set the comment's side to match.
-      threadEl.comments = newComments.map(c => {
-        return Object.assign(c, {__commentSide: threadEl.commentSide});
-      });
+      threadEl.comments = newComments.map(c => Object.assign(
+          c, {__commentSide: threadEl.commentSide}
+      ));
       Polymer.dom.flush();
       return;
-    },
+    }
 
     _handleEscKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
       e.preventDefault();
       this._displayLine = false;
-    },
+    }
 
     /**
      * Update the loading class for the file list rows. The update is inside a
@@ -1156,19 +1170,19 @@
         // this way, the gray loading style is not shown on initial loads.
         this.classList.toggle('loading', loading && !!this._files.length);
       }, LOADING_DEBOUNCE_INTERVAL);
-    },
+    }
 
     _editModeChanged(editMode) {
       this.classList.toggle('editMode', editMode);
-    },
+    }
 
     _computeReviewedClass(isReviewed) {
       return isReviewed ? 'isReviewed' : '';
-    },
+    }
 
     _computeReviewedText(isReviewed) {
       return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-    },
+    }
 
     /**
      * Given a file path, return whether that path should have visible size bars
@@ -1179,7 +1193,7 @@
      */
     _showBarsForPath(path) {
       return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
-    },
+    }
 
     /**
      * Compute size bar layout values from the file list.
@@ -1215,7 +1229,7 @@
         stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
       }
       return stats;
-    },
+    }
 
     /**
      * Get the width of the addition bar for a file.
@@ -1233,7 +1247,7 @@
       const width =
           stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
       return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-    },
+    }
 
     /**
      * Get the x-offset of the addition bar for a file.
@@ -1245,7 +1259,7 @@
     _computeBarAdditionX(file, stats) {
       return stats.maxAdditionWidth -
           this._computeBarAdditionWidth(file, stats);
-    },
+    }
 
     /**
      * Get the width of the deletion bar for a file.
@@ -1263,7 +1277,7 @@
       const width =
           stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
       return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-    },
+    }
 
     /**
      * Get the x-offset of the deletion bar for a file.
@@ -1274,11 +1288,11 @@
      */
     _computeBarDeletionX(stats) {
       return stats.deletionOffset;
-    },
+    }
 
     _computeShowSizeBars(userPrefs) {
       return !!userPrefs.size_bar_in_change_table;
-    },
+    }
 
     _computeSizeBarsClass(showSizeBars, path) {
       let hideClass = '';
@@ -1288,7 +1302,7 @@
         hideClass = 'invisible';
       }
       return `sizeBars desktop ${hideClass}`;
-    },
+    }
 
     /**
      * Shows registered dynamic columns iff the 'header', 'content' and
@@ -1301,7 +1315,7 @@
       return headerEndpoints && contentEndpoints && summaryEndpoints &&
              headerEndpoints.length === contentEndpoints.length &&
              headerEndpoints.length === summaryEndpoints.length;
-    },
+    }
 
     /**
      * Returns true if none of the inline diffs have been expanded.
@@ -1310,7 +1324,7 @@
      */
     _noDiffsExpanded() {
       return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
-    },
+    }
 
     /**
      * Method to call via binding when each file list row is rendered. This
@@ -1328,7 +1342,7 @@
         }, 1);
       }
       return '';
-    },
+    }
 
     _reviewedTitle(reviewed) {
       if (reviewed) {
@@ -1336,12 +1350,14 @@
       }
 
       return 'Mark as reviewed (shortcut: r)';
-    },
+    }
 
     _handleReloadingDiffPreference() {
       this._getDiffPreferences().then(prefs => {
         this.diffPrefs = prefs;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrFileList.is, GrFileList);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 4c102a2..04da0c7 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-file-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -115,7 +115,7 @@
         patchNum: '2',
       };
       saveStub = sandbox.stub(element, '_saveReviewedState',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
     });
 
     teardown(() => {
@@ -647,9 +647,10 @@
         assert.equal(element.$.fileCursor.index, 0);
         assert.equal(element.selectedIndex, 0);
 
-        sandbox.stub(element, '_addDraftAtTarget');
+        const createCommentInPlaceStub = sandbox.stub(element.$.diffCursor,
+            'createCommentInPlace');
         MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
-        assert.isTrue(element._addDraftAtTarget.called);
+        assert.isTrue(createCommentInPlaceStub.called);
       });
 
       test('i key shows/hides selected inline diff', () => {
@@ -685,7 +686,7 @@
       });
 
       test('r key toggles reviewed flag', () => {
-        const reducer = (accum, file) => file.isReviewed ? ++accum : accum;
+        const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
         const getNumReviewed = () => element._files.reduce(reducer, 0);
         flushAsynchronousOperations();
 
@@ -864,7 +865,8 @@
 
       // Click inside the diff. This should result in no additional calls to
       // _togglePathExpanded or _reviewFile.
-      Polymer.dom(element.root).querySelector('gr-diff-host').click();
+      Polymer.dom(element.root).querySelector('gr-diff-host')
+          .click();
       assert.isTrue(clickSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
@@ -1475,9 +1477,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      sandbox.stub(window, 'fetch', () => {
-        return Promise.resolve();
-      });
+      sandbox.stub(window, 'fetch', () => Promise.resolve());
       flushAsynchronousOperations();
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index 4b8ce22..01c9b6e 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -17,36 +17,41 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-included-in-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrIncludedInDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-included-in-dialog'; }
     /**
      * Fired when the user presses the close button.
      *
      * @event close
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      changeNum: {
-        type: Object,
-        observer: '_resetData',
-      },
-      /** @type {?} */
-      _includedIn: Object,
-      _loaded: {
-        type: Boolean,
-        value: false,
-      },
-      _filterText: {
-        type: String,
-        value: '',
-      },
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+        changeNum: {
+          type: Object,
+          observer: '_resetData',
+        },
+        /** @type {?} */
+        _includedIn: Object,
+        _loaded: {
+          type: Boolean,
+          value: false,
+        },
+        _filterText: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
     loadData() {
       if (!this.changeNum) { return; }
@@ -57,12 +62,12 @@
             this._includedIn = configs;
             this._loaded = true;
           });
-    },
+    }
 
     _resetData() {
       this._includedIn = null;
       this._loaded = false;
-    },
+    }
 
     _computeGroups(includedIn, filterText) {
       if (!includedIn) { return []; }
@@ -83,22 +88,24 @@
         }
       }
       return groups.filter(g => g.items.length);
-    },
+    }
 
     _handleCloseTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('close', null, {bubbles: false});
-    },
+    }
 
     _computeLoadingClass(loaded) {
       return loaded ? 'loading loaded' : 'loading';
-    },
+    }
 
     _onFilterChanged() {
       this.debounce('filter-change', () => {
         this._filterText = this.$.filterInput.bindValue;
       }, 100);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
index 68c77e6..b97e0b4 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-included-in-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
index 220546b..46fd227 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
@@ -23,7 +23,9 @@
 
 <dom-module id="gr-label-score-row">
   <template>
-    <style include="gr-voting-styles"></style>
+    <style include="gr-voting-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       .labelContainer {
         align-items: center;
@@ -109,7 +111,7 @@
         </template>
         <iron-selector
             id="labelSelector"
-            attr-for-selected="value"
+            attr-for-selected="data-value"
             selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
             hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
             on-selected-item-changed="_setSelectedValueText">
@@ -119,8 +121,8 @@
             <gr-button
                 class$="[[_computeButtonClass(value, index, _items.length)]]"
                 has-tooltip
-                name="[[label.name]]"
-                value$="[[value]]"
+                data-name$="[[label.name]]"
+                data-value$="[[value]]"
                 title$="[[_computeLabelValueTitle(labels, label.name, value)]]">
               [[value]]</gr-button>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 76e6e64..1f9845e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -17,56 +17,60 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-label-score-row',
-
+  /** @extends Polymer.Element */
+  class GrLabelScoreRow extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-label-score-row'; }
     /**
      * Fired when any label is changed.
      *
      * @event labels-changed
      */
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * @type {{ name: string }}
        */
-      label: Object,
-      labels: Object,
-      name: {
-        type: String,
-        reflectToAttribute: true,
-      },
-      permittedLabels: Object,
-      labelValues: Object,
-      _selectedValueText: {
-        type: String,
-        value: 'No value selected',
-      },
-      _items: {
-        type: Array,
-        computed: '_computePermittedLabelValues(permittedLabels, label.name)',
-      },
-    },
+        label: Object,
+        labels: Object,
+        name: {
+          type: String,
+          reflectToAttribute: true,
+        },
+        permittedLabels: Object,
+        labelValues: Object,
+        _selectedValueText: {
+          type: String,
+          value: 'No value selected',
+        },
+        _items: {
+          type: Array,
+          computed: '_computePermittedLabelValues(permittedLabels, label.name)',
+        },
+      };
+    }
 
     get selectedItem() {
       if (!this._ironSelector) { return undefined; }
       return this._ironSelector.selectedItem;
-    },
+    }
 
     get selectedValue() {
       if (!this._ironSelector) { return undefined; }
       return this._ironSelector.selected;
-    },
+    }
 
     setSelectedValue(value) {
       // The selector may not be present if it’s not at the latest patch set.
       if (!this._ironSelector) { return; }
       this._ironSelector.select(value);
-    },
+    }
 
     get _ironSelector() {
       return this.$ && this.$.labelSelector;
-    },
+    }
 
     _computeBlankItems(permittedLabels, label, side) {
       if (!permittedLabels || !permittedLabels[label] ||
@@ -82,7 +86,7 @@
       const endPosition = this.labelValues[parseInt(
           permittedLabels[label][permittedLabels[label].length - 1], 10)];
       return new Array(Object.keys(this.labelValues).length - endPosition - 1);
-    },
+    }
 
     _getLabelValue(labels, permittedLabels, label) {
       if (label.value) {
@@ -93,7 +97,7 @@
         return permittedLabels[label.name].find(
             value => parseInt(value, 10) === labels[label.name].default_value);
       }
-    },
+    }
 
     _computeButtonClass(value, index, totalItems) {
       const classes = [];
@@ -114,7 +118,7 @@
       }
 
       return classes.join(' ');
-    },
+    }
 
     _computeLabelValue(labels, permittedLabels, label) {
       if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
@@ -131,7 +135,7 @@
         }
       }
       return null;
-    },
+    }
 
     _setSelectedValueText(e) {
       // Needed because when the selected item changes, it first changes to
@@ -140,22 +144,22 @@
       this._selectedValueText = e.target.selectedItem.getAttribute('title');
       // Needed to update the style of the selected button.
       this.updateStyles();
-      const name = e.target.selectedItem.name;
-      const value = e.target.selectedItem.getAttribute('value');
+      const name = e.target.selectedItem.dataset.name;
+      const value = e.target.selectedItem.dataset.value;
       this.dispatchEvent(new CustomEvent(
           'labels-changed',
           {detail: {name, value}, bubbles: true, composed: true}));
-    },
+    }
 
     _computeAnyPermittedLabelValues(permittedLabels, label) {
       return permittedLabels && permittedLabels.hasOwnProperty(label) &&
         permittedLabels[label].length;
-    },
+    }
 
     _computeHiddenClass(permittedLabels, label) {
       return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
         'hidden' : '';
-    },
+    }
 
     _computePermittedLabelValues(permittedLabels, label) {
       // Polymer 2: check for undefined
@@ -164,12 +168,14 @@
       }
 
       return permittedLabels[label];
-    },
+    }
 
     _computeLabelValueTitle(labels, label, value) {
       return labels[label] &&
         labels[label].values &&
         labels[label].values[value];
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLabelScoreRow.is, GrLabelScoreRow);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index 519fbb8..e9c21bc 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-score-row</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -110,7 +110,7 @@
       element.addEventListener('labels-changed', labelsChangedHandler);
       assert.ok(element.$.labelSelector);
       MockInteractions.tap(element.$$(
-          'gr-button[value="-1"]'));
+          'gr-button[data-value="-1"]'));
       flushAsynchronousOperations();
       assert.strictEqual(element.selectedValue, '-1');
       assert.strictEqual(element.selectedItem
@@ -167,7 +167,7 @@
 
     test('do not display tooltips on touch devices', () => {
       const verifiedBtn = element.$$(
-          'iron-selector > gr-button[value="-1"]');
+          'iron-selector > gr-button[data-value="-1"]');
 
       // On touch devices, tooltips should not be shown.
       verifiedBtn._isTouchDevice = true;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
index dffba3e..0cbf2c7 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -17,22 +17,30 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-label-scores',
+  /** @extends Polymer.Element */
+  class GrLabelScores extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-label-scores'; }
 
-    properties: {
-      _labels: {
-        type: Array,
-        computed: '_computeLabels(change.labels.*, account)',
-      },
-      permittedLabels: {
-        type: Object,
-        observer: '_computeColumns',
-      },
-      /** @type {?} */
-      change: Object,
-      _labelValues: Object,
-    },
+    static get properties() {
+      return {
+        _labels: {
+          type: Array,
+          computed: '_computeLabels(change.labels.*, account)',
+        },
+        permittedLabels: {
+          type: Object,
+          observer: '_computeColumns',
+        },
+        /** @type {?} */
+        change: Object,
+        /** @type {?} */
+        account: Object,
+
+        _labelValues: Object,
+      };
+    }
 
     getLabelValues() {
       const labels = {};
@@ -58,7 +66,7 @@
         }
       }
       return labels;
-    },
+    }
 
     _getStringLabelValue(labels, labelName, numberValue) {
       for (const k in labels[labelName].values) {
@@ -67,7 +75,7 @@
         }
       }
       return numberValue;
-    },
+    }
 
     _getVoteForAccount(labels, labelName, account) {
       const votes = labels[labelName];
@@ -80,7 +88,7 @@
         }
       }
       return null;
-    },
+    }
 
     _computeLabels(labelRecord, account) {
       // Polymer 2: check for undefined
@@ -90,13 +98,14 @@
 
       const labelsObj = labelRecord.base;
       if (!labelsObj) { return []; }
-      return Object.keys(labelsObj).sort().map(key => {
-        return {
-          name: key,
-          value: this._getVoteForAccount(labelsObj, key, this.account),
-        };
-      });
-    },
+      return Object.keys(labelsObj).sort()
+          .map(key => {
+            return {
+              name: key,
+              value: this._getVoteForAccount(labelsObj, key, this.account),
+            };
+          });
+    }
 
     _computeColumns(permittedLabels) {
       const labels = Object.keys(permittedLabels);
@@ -107,19 +116,17 @@
         }
       }
 
-      const orderedValues = Object.keys(values).sort((a, b) => {
-        return a - b;
-      });
+      const orderedValues = Object.keys(values).sort((a, b) => a - b);
 
       for (let i = 0; i < orderedValues.length; i++) {
         values[orderedValues[i]] = i;
       }
       this._labelValues = values;
-    },
+    }
 
     _changeIsMerged(changeStatus) {
       return changeStatus === 'MERGED';
-    },
+    }
 
     /**
      * @param {string|undefined} label
@@ -133,6 +140,8 @@
 
       return permittedLabels.hasOwnProperty(label) &&
         permittedLabels[label].length ? 'access' : 'no-access';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLabelScores.is, GrLabelScores);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
index b8d471c..4c489b7 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-scores</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index da5eb39..fbd5d68d 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -31,7 +31,9 @@
 
 <dom-module id="gr-message">
   <template>
-    <style include="gr-voting-styles"></style>
+    <style include="gr-voting-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       :host {
         border-bottom: 1px solid var(--border-color);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 26e0cd3..59f12f5 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -20,9 +20,16 @@
   const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
   const LABEL_TITLE_SCORE_PATTERN = /^([A-Za-z0-9-]+)([+-]\d+)$/;
 
-  Polymer({
-    is: 'gr-message',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrMessage extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-message'; }
     /**
      * Fired when this message's reply link is tapped.
      *
@@ -35,90 +42,95 @@
      * @event message-anchor-tap
      */
 
-    listeners: {
-      click: '_handleClick',
-    },
+    static get properties() {
+      return {
+        changeNum: Number,
+        /** @type {?} */
+        message: Object,
+        author: {
+          type: Object,
+          computed: '_computeAuthor(message)',
+        },
+        comments: {
+          type: Object,
+          observer: '_commentsChanged',
+        },
+        config: Object,
+        hideAutomated: {
+          type: Boolean,
+          value: false,
+        },
+        hidden: {
+          type: Boolean,
+          computed: '_computeIsHidden(hideAutomated, isAutomated)',
+          reflectToAttribute: true,
+        },
+        isAutomated: {
+          type: Boolean,
+          computed: '_computeIsAutomated(message)',
+        },
+        showAvatar: {
+          type: Boolean,
+          computed: '_computeShowAvatar(author, config)',
+        },
+        showOnBehalfOf: {
+          type: Boolean,
+          computed: '_computeShowOnBehalfOf(message)',
+        },
+        showReplyButton: {
+          type: Boolean,
+          computed: '_computeShowReplyButton(message, _loggedIn)',
+        },
+        projectName: {
+          type: String,
+          observer: '_projectNameChanged',
+        },
 
-    properties: {
-      changeNum: Number,
-      /** @type {?} */
-      message: Object,
-      author: {
-        type: Object,
-        computed: '_computeAuthor(message)',
-      },
-      comments: {
-        type: Object,
-        observer: '_commentsChanged',
-      },
-      config: Object,
-      hideAutomated: {
-        type: Boolean,
-        value: false,
-      },
-      hidden: {
-        type: Boolean,
-        computed: '_computeIsHidden(hideAutomated, isAutomated)',
-        reflectToAttribute: true,
-      },
-      isAutomated: {
-        type: Boolean,
-        computed: '_computeIsAutomated(message)',
-      },
-      showAvatar: {
-        type: Boolean,
-        computed: '_computeShowAvatar(author, config)',
-      },
-      showOnBehalfOf: {
-        type: Boolean,
-        computed: '_computeShowOnBehalfOf(message)',
-      },
-      showReplyButton: {
-        type: Boolean,
-        computed: '_computeShowReplyButton(message, _loggedIn)',
-      },
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
+        /**
+         * A mapping from label names to objects representing the minimum and
+         * maximum possible values for that label.
+         */
+        labelExtremes: Object,
 
-      /**
-       * A mapping from label names to objects representing the minimum and
-       * maximum possible values for that label.
-       */
-      labelExtremes: Object,
+        /**
+         * @type {{ commentlinks: Array }}
+         */
+        _projectConfig: Object,
+        // Computed property needed to trigger Polymer value observing.
+        _expanded: {
+          type: Object,
+          computed: '_computeExpanded(message.expanded)',
+        },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-      /**
-       * @type {{ commentlinks: Array }}
-       */
-      _projectConfig: Object,
-      // Computed property needed to trigger Polymer value observing.
-      _expanded: {
-        type: Object,
-        computed: '_computeExpanded(message.expanded)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get observers() {
+      return [
+        '_updateExpandedClass(message.expanded)',
+      ];
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('click',
+          e => this._handleClick(e));
+    }
 
-    observers: [
-      '_updateExpandedClass(message.expanded)',
-    ],
-
+    /** @override */
     ready() {
+      super.ready();
       this.$.restAPI.getConfig().then(config => {
         this.config = config;
       });
       this.$.restAPI.getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
       });
-    },
+    }
 
     _updateExpandedClass(expanded) {
       if (expanded) {
@@ -126,30 +138,30 @@
       } else {
         this.classList.remove('expanded');
       }
-    },
+    }
 
     _computeAuthor(message) {
       return message.author || message.updated_by;
-    },
+    }
 
     _computeShowAvatar(author, config) {
       return !!(author && config && config.plugin && config.plugin.has_avatars);
-    },
+    }
 
     _computeShowOnBehalfOf(message) {
       const author = message.author || message.updated_by;
       return !!(author && message.real_author &&
           author._account_id != message.real_author._account_id);
-    },
+    }
 
     _computeShowReplyButton(message, loggedIn) {
       return message && !!message.message && loggedIn &&
           !this._computeIsAutomated(message);
-    },
+    }
 
     _computeExpanded(expanded) {
       return expanded;
-    },
+    }
 
     /**
      * If there is no value set on the message object as to whether _expanded
@@ -160,33 +172,33 @@
       if (this.message && this.message.expanded === undefined) {
         this.set('message.expanded', Object.keys(value || {}).length > 0);
       }
-    },
+    }
 
     _handleClick(e) {
       if (this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', true);
-    },
+    }
 
     _handleAuthorClick(e) {
       if (!this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', false);
-    },
+    }
 
     _computeIsAutomated(message) {
       return !!(message.reviewer ||
           this._computeIsReviewerUpdate(message) ||
           (message.tag && message.tag.startsWith('autogenerated')));
-    },
+    }
 
     _computeIsHidden(hideAutomated, isAutomated) {
       return hideAutomated && isAutomated;
-    },
+    }
 
     _computeIsReviewerUpdate(event) {
       return event.type === 'REVIEWER_UPDATE';
-    },
+    }
 
     _getScores(message) {
       if (!message.message) { return []; }
@@ -198,8 +210,8 @@
       return scoresRaw.split(' ')
           .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
           .filter(ms => ms && ms.length === 3)
-          .map(ms => ({label: ms[1], value: ms[2]}));
-    },
+          .map(ms => { return {label: ms[1], value: ms[2]}; });
+    }
 
     _computeScoreClass(score, labelExtremes) {
       // Polymer 2: check for undefined
@@ -222,14 +234,14 @@
         }
       }
       return classes.join(' ');
-    },
+    }
 
     _computeClass(expanded, showAvatar, message) {
       const classes = [];
       classes.push(expanded ? 'expanded' : 'collapsed');
       classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
       return classes.join(' ');
-    },
+    }
 
     _handleAnchorClick(e) {
       e.preventDefault();
@@ -238,26 +250,28 @@
         composed: true,
         detail: {id: this.message.id},
       }));
-    },
+    }
 
     _handleReplyTap(e) {
       e.preventDefault();
       this.fire('reply', {message: this.message});
-    },
+    }
 
     _projectNameChanged(name) {
       this.$.restAPI.getProjectConfig(name).then(config => {
         this._projectConfig = config;
       });
-    },
+    }
 
     _computeExpandToggleIcon(expanded) {
       return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-    },
+    }
 
     _toggleExpanded(e) {
       e.stopPropagation();
       this.set('message.expanded', !this.message.expanded);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrMessage.is, GrMessage);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index ef5a756..01bc691 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-message</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 5b6bfbf..2c47dc1 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -25,57 +25,62 @@
     SHOW_MORE: 'show-more-messages',
   };
 
-  Polymer({
-    is: 'gr-messages-list',
+  /** @extends Polymer.Element */
+  class GrMessagesList extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-messages-list'; }
 
-    properties: {
-      changeNum: Number,
-      messages: {
-        type: Array,
-        value() { return []; },
-      },
-      reviewerUpdates: {
-        type: Array,
-        value() { return []; },
-      },
-      changeComments: Object,
-      projectName: String,
-      showReplyButtons: {
-        type: Boolean,
-        value: false,
-      },
-      labels: Object,
+    static get properties() {
+      return {
+        changeNum: Number,
+        messages: {
+          type: Array,
+          value() { return []; },
+        },
+        reviewerUpdates: {
+          type: Array,
+          value() { return []; },
+        },
+        changeComments: Object,
+        projectName: String,
+        showReplyButtons: {
+          type: Boolean,
+          value: false,
+        },
+        labels: Object,
 
-      _expanded: {
-        type: Boolean,
-        value: false,
-        observer: '_expandedChanged',
-      },
-      _hideAutomated: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * The messages after processing and including merged reviewer updates.
-       */
-      _processedMessages: {
-        type: Array,
-        computed: '_computeItems(messages, reviewerUpdates)',
-        observer: '_processedMessagesChanged',
-      },
-      /**
-       * The subset of _processedMessages that is visible to the user.
-       */
-      _visibleMessages: {
-        type: Array,
-        value() { return []; },
-      },
+        _expanded: {
+          type: Boolean,
+          value: false,
+          observer: '_expandedChanged',
+        },
+        _hideAutomated: {
+          type: Boolean,
+          value: false,
+        },
+        /**
+         * The messages after processing and including merged reviewer updates.
+         */
+        _processedMessages: {
+          type: Array,
+          computed: '_computeItems(messages, reviewerUpdates)',
+          observer: '_processedMessagesChanged',
+        },
+        /**
+         * The subset of _processedMessages that is visible to the user.
+         */
+        _visibleMessages: {
+          type: Array,
+          value() { return []; },
+        },
 
-      _labelExtremes: {
-        type: Object,
-        computed: '_computeLabelExtremes(labels.*)',
-      },
-    },
+        _labelExtremes: {
+          type: Object,
+          computed: '_computeLabelExtremes(labels.*)',
+        },
+      };
+    }
 
     scrollToMessage(messageID) {
       let el = this.$$('[data-message-id="' + messageID + '"]');
@@ -108,12 +113,12 @@
       }
       window.scrollTo(0, top);
       this._highlightEl(el);
-    },
+    }
 
     _isAutomated(message) {
       return !!(message.reviewer ||
           (message.tag && message.tag.startsWith('autogenerated')));
-    },
+    }
 
     _computeItems(messages, reviewerUpdates) {
       // Polymer 2: check for undefined
@@ -152,7 +157,7 @@
         }
       }
       return result;
-    },
+    }
 
     _expandedChanged(exp) {
       if (this._processedMessages) {
@@ -169,7 +174,7 @@
           this.notifyPath(`_visibleMessages.${i}.expanded`);
         }
       }
-    },
+    }
 
     _highlightEl(el) {
       const highlightedEls =
@@ -183,23 +188,23 @@
       }
       el.addEventListener('animationend', handleAnimationEnd);
       el.classList.add('highlighted');
-    },
+    }
 
     /**
      * @param {boolean} expand
      */
     handleExpandCollapse(expand) {
       this._expanded = expand;
-    },
+    }
 
     _handleExpandCollapseTap(e) {
       e.preventDefault();
       this.handleExpandCollapse(!this._expanded);
-    },
+    }
 
     _handleAnchorClick(e) {
       this.scrollToMessage(e.detail.id);
-    },
+    }
 
     _hasAutomatedMessages(messages) {
       if (!messages) { return false; }
@@ -209,11 +214,11 @@
         }
       }
       return false;
-    },
+    }
 
     _computeExpandCollapseMessage(expanded) {
       return expanded ? 'Collapse all' : 'Expand all';
-    },
+    }
 
     /**
      * Computes message author's file comments for change's message.
@@ -269,7 +274,7 @@
         }
       }
       return msgComments;
-    },
+    }
 
     /**
      * Returns the number of messages to splice to the beginning of
@@ -294,7 +299,7 @@
         delta = msgsRemaining - i;
       }
       return Math.min(msgsRemaining, delta);
-    },
+    }
 
     /**
      * Gets the number of messages that would be visible, but do not currently
@@ -310,20 +315,18 @@
             this._getHumanMessages(visibleMessages).length;
       }
       return messages.length - visibleMessages.length;
-    },
+    }
 
     _computeIncrementText(visibleMessages, messages, hideAutomated) {
       let delta = this._getDelta(visibleMessages, messages, hideAutomated);
       delta = Math.min(
           this._numRemaining(visibleMessages, messages, hideAutomated), delta);
       return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
-    },
+    }
 
     _getHumanMessages(messages) {
-      return messages.filter(msg => {
-        return !this._isAutomated(msg);
-      });
-    },
+      return messages.filter(msg => !this._isAutomated(msg));
+    }
 
     _computeShowHideTextHidden(visibleMessages, messages,
         hideAutomated) {
@@ -336,12 +339,12 @@
         visibleMessages = this._getHumanMessages(visibleMessages);
       }
       return visibleMessages.length >= messages.length;
-    },
+    }
 
     _handleShowAllTap() {
       this._visibleMessages = this._processedMessages;
       this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
-    },
+    }
 
     _handleIncrementShownMessages() {
       const delta = this._getDelta(this._visibleMessages,
@@ -351,27 +354,27 @@
       // Add newMessages to the beginning of _visibleMessages
       this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
       this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
-    },
+    }
 
     _processedMessagesChanged(messages) {
       if (messages) {
         this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
       }
-    },
+    }
 
     _computeNumMessagesText(visibleMessages, messages,
         hideAutomated) {
       const total =
           this._numRemaining(visibleMessages, messages, hideAutomated);
       return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
-    },
+    }
 
     _computeIncrementHidden(visibleMessages, messages,
         hideAutomated) {
       const total =
           this._numRemaining(visibleMessages, messages, hideAutomated);
       return total <= this._getDelta(visibleMessages, messages, hideAutomated);
-    },
+    }
 
     /**
      * Compute a mapping from label name to objects representing the minimum and
@@ -390,6 +393,8 @@
         extremes[key] = {min: values[0], max: values[values.length - 1]};
       }
       return extremes;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrMessagesList.is, GrMessagesList);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index 315403e..b0747f4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-messages-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -49,7 +49,6 @@
 </test-fixture>
 
 <script>
-
   const randomMessage = function(opt_params) {
     const params = opt_params || {};
     const author1 = {
@@ -232,7 +231,8 @@
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
 
-      MockInteractions.tap(element.$$('#collapse-messages')); // Expand all.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages')); // Expand all.
       flushAsynchronousOperations();
 
       let messages = getMessages();
@@ -256,8 +256,10 @@
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
 
-      MockInteractions.tap(element.$$('#collapse-messages')); // Expand all.
-      MockInteractions.tap(element.$$('#collapse-messages')); // Collapse all.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages')); // Expand all.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages')); // Collapse all.
       flushAsynchronousOperations();
 
       let messages = getMessages();
@@ -284,13 +286,15 @@
       MockInteractions.tap(allMessageEls[1]);
       assert.isTrue(allMessageEls[1]._expanded);
 
-      MockInteractions.tap(element.$$('#collapse-messages'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isTrue(message._expanded);
       }
 
-      MockInteractions.tap(element.$$('#collapse-messages'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isFalse(message._expanded);
@@ -298,28 +302,33 @@
     });
 
     test('expand/collapse from external keypress', () => {
-      MockInteractions.tap(element.$$('#collapse-messages'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
       let allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isTrue(message._expanded);
       }
 
       // Expand/collapse all text also changes.
-      assert.equal(element.$$('#collapse-messages').textContent.trim(),
-          'Collapse all');
+      assert.equal(element.shadowRoot
+          .querySelector('#collapse-messages').textContent.trim(),
+      'Collapse all');
 
-      MockInteractions.tap(element.$$('#collapse-messages'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
         assert.isFalse(message._expanded);
       }
       // Expand/collapse all text also changes.
-      assert.equal(element.$$('#collapse-messages').textContent.trim(),
-          'Expand all');
+      assert.equal(element.shadowRoot
+          .querySelector('#collapse-messages').textContent.trim(),
+      'Expand all');
     });
 
     test('hide messages does not appear when no automated messages', () => {
-      assert.isOk(element.$$('#automatedMessageToggleContainer[hidden]'));
+      assert.isOk(element.shadowRoot
+          .querySelector('#automatedMessageToggleContainer[hidden]'));
     });
 
     test('scroll to message', () => {
@@ -477,7 +486,8 @@
     });
 
     test('hide autogenerated button is not hidden', () => {
-      assert.isNotOk(element.$$('#automatedMessageToggle[hidden]'));
+      assert.isNotOk(element.shadowRoot
+          .querySelector('#automatedMessageToggle[hidden]'));
     });
 
     test('autogenerated messages are not hidden initially', () => {
@@ -517,7 +527,6 @@
       assert.equal(element._getDelta([], messages, false), 1);
       assert.equal(element._getDelta([], messages, true), 1);
 
-
       messages = _.times(7, randomMessage);
       assert.equal(element._getDelta([], messages, false), 5);
       assert.equal(element._getDelta([], messages, true), 5);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 9103f4f..d4a2398 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -17,9 +17,20 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-related-changes-list',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrRelatedChangesList extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-related-changes-list'; }
     /**
      * Fired when a new section is loaded so that the change view can determine
      * a show more button is needed, sometimes before all the sections finish
@@ -28,64 +39,62 @@
      * @event new-section-loaded
      */
 
-    properties: {
-      change: Object,
-      hasParent: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
-      patchNum: String,
-      parentChange: Object,
-      hidden: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        notify: true,
-      },
-      mergeable: Boolean,
-      _connectedRevisions: {
-        type: Array,
-        computed: '_computeConnectedRevisions(change, patchNum, ' +
+    static get properties() {
+      return {
+        change: Object,
+        hasParent: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
+        patchNum: String,
+        parentChange: Object,
+        hidden: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        loading: {
+          type: Boolean,
+          notify: true,
+        },
+        mergeable: Boolean,
+        _connectedRevisions: {
+          type: Array,
+          computed: '_computeConnectedRevisions(change, patchNum, ' +
             '_relatedResponse.changes)',
-      },
-      /** @type {?} */
-      _relatedResponse: {
-        type: Object,
-        value() { return {changes: []}; },
-      },
-      /** @type {?} */
-      _submittedTogether: {
-        type: Object,
-        value() { return {changes: []}; },
-      },
-      _conflicts: {
-        type: Array,
-        value() { return []; },
-      },
-      _cherryPicks: {
-        type: Array,
-        value() { return []; },
-      },
-      _sameTopic: {
-        type: Array,
-        value() { return []; },
-      },
-    },
+        },
+        /** @type {?} */
+        _relatedResponse: {
+          type: Object,
+          value() { return {changes: []}; },
+        },
+        /** @type {?} */
+        _submittedTogether: {
+          type: Object,
+          value() { return {changes: []}; },
+        },
+        _conflicts: {
+          type: Array,
+          value() { return []; },
+        },
+        _cherryPicks: {
+          type: Array,
+          value() { return []; },
+        },
+        _sameTopic: {
+          type: Array,
+          value() { return []; },
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
-    observers: [
-      '_resultsChanged(_relatedResponse, _submittedTogether, ' +
+    static get observers() {
+      return [
+        '_resultsChanged(_relatedResponse, _submittedTogether, ' +
           '_conflicts, _cherryPicks, _sameTopic)',
-    ],
+      ];
+    }
 
     clear() {
       this.loading = true;
@@ -96,7 +105,7 @@
       this._conflicts = [];
       this._cherryPicks = [];
       this._sameTopic = [];
-    },
+    }
 
     reload() {
       if (!this.change || !this.patchNum) {
@@ -144,7 +153,7 @@
       return Promise.all(promises).then(() => {
         this.loading = false;
       });
-    },
+    }
 
     _fireReloadEvent() {
       // The listener on the change computes height of the related changes
@@ -152,7 +161,7 @@
       // that requires a flush.
       Polymer.dom.flush();
       this.dispatchEvent(new CustomEvent('new-section-loaded'));
-    },
+    }
 
     /**
      * Determines whether or not the given change has a parent change. If there
@@ -167,34 +176,34 @@
       return relatedChanges.length > 0 &&
           relatedChanges[relatedChanges.length - 1].change_id !==
           currentChangeId;
-    },
+    }
 
     _getRelatedChanges() {
       return this.$.restAPI.getRelatedChanges(this.change._number,
           this.patchNum);
-    },
+    }
 
     _getSubmittedTogether() {
       return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
-    },
+    }
 
     _getServerConfig() {
       return this.$.restAPI.getConfig();
-    },
+    }
 
     _getConflicts() {
       return this.$.restAPI.getChangeConflicts(this.change._number);
-    },
+    }
 
     _getCherryPicks() {
       return this.$.restAPI.getChangeCherryPicks(this.change.project,
           this.change.change_id, this.change._number);
-    },
+    }
 
     _getChangesWithSameTopic() {
       return this.$.restAPI.getChangesWithSameTopic(this.change.topic,
           this.change._number);
-    },
+    }
 
     /**
      * @param {number} changeNum
@@ -204,7 +213,7 @@
      */
     _computeChangeURL(changeNum, project, opt_patchNum) {
       return Gerrit.Nav.getUrlForChangeById(changeNum, project, opt_patchNum);
-    },
+    }
 
     _computeChangeContainerClass(currentChange, relatedChange) {
       const classes = ['changeContainer'];
@@ -215,7 +224,7 @@
         classes.push('thisChange');
       }
       return classes.join(' ');
-    },
+    }
 
     /**
      * Do the given objects describe the same change? Compares the changes by
@@ -231,7 +240,7 @@
       const aNum = this._getChangeNumber(a);
       const bNum = this._getChangeNumber(b);
       return aNum === bNum;
-    },
+    }
 
     /**
      * Get the change number from either a ChangeInfo (such as those included in
@@ -254,7 +263,7 @@
         return change._change_number;
       }
       return change._number;
-    },
+    }
 
     _computeLinkClass(change) {
       const statuses = [];
@@ -265,7 +274,7 @@
         statuses.push('submittable');
       }
       return statuses.join(' ');
-    },
+    }
 
     _computeChangeStatusClass(change) {
       const classes = ['status'];
@@ -279,7 +288,7 @@
         classes.push('hidden');
       }
       return classes.join(' ');
-    },
+    }
 
     _computeChangeStatus(change) {
       switch (change.status) {
@@ -296,7 +305,7 @@
         return 'Submittable';
       }
       return '';
-    },
+    }
 
     _resultsChanged(related, submittedTogether, conflicts,
         cherryPicks, sameTopic) {
@@ -326,11 +335,11 @@
         }
       }
       this.hidden = true;
-    },
+    }
 
     _isIndirectAncestor(change) {
       return !this._connectedRevisions.includes(change.commit.commit);
-    },
+    }
 
     _computeConnectedRevisions(change, patchNum, relatedChanges) {
       // Polymer 2: check for undefined
@@ -346,7 +355,7 @@
           changeRevision = rev;
         }
       }
-      const commits = relatedChanges.map(c => { return c.commit; });
+      const commits = relatedChanges.map(c => c.commit);
       let pos = commits.length - 1;
 
       while (pos >= 0) {
@@ -367,7 +376,7 @@
         --pos;
       }
       return connected;
-    },
+    }
 
     _computeSubmittedTogetherClass(submittedTogether) {
       if (!submittedTogether || (
@@ -376,11 +385,13 @@
         return 'hidden';
       }
       return '';
-    },
+    }
 
     _computeNonVisibleChangesNote(n) {
       const noun = n === 1 ? 'change' : 'changes';
       return `(+ ${n} non-visible ${noun})`;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRelatedChangesList.is, GrRelatedChangesList);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index f04d40e..38b2fb9 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-related-changes-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index 3632348..7d95323 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -156,7 +156,7 @@
             const labelScoreRows = Polymer.dom(element.$.labelScores.root)
                 .querySelector('gr-label-score-row[name="Code-Review"]');
             const selectedBtn = Polymer.dom(labelScoreRows.root)
-                .querySelector('gr-button[value="+1"].iron-selected');
+                .querySelector('gr-button[data-value="+1"].iron-selected');
             assert.isOk(selectedBtn);
             done();
           });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index e836ccc..d54669f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -86,19 +86,17 @@
       }
       .peopleList {
         display: flex;
-        padding-top: var(--spacing-xxs);
       }
       .peopleListLabel {
         color: var(--deemphasized-text-color);
         margin-top: var(--spacing-xs);
-        min-width: 7em;
+        min-width: 6em;
         padding-right: var(--spacing-m);
       }
       gr-account-list {
         display: flex;
         flex-wrap: wrap;
         flex: 1;
-        min-height: 1.8em;
       }
       #reviewerConfirmationOverlay {
         padding: var(--spacing-l);
@@ -269,16 +267,6 @@
       </section>
       <section class="actions">
         <div class="left">
-          <template is="dom-if" if="[[canBeStarted]]">
-            <gr-button
-                link
-                secondary
-                disabled="[[_isState(knownLatestState, 'not-latest')]]"
-                class="action save"
-                has-tooltip
-                title="[[_saveTooltip]]"
-                on-click="_saveTapHandler">Save</gr-button>
-          </template>
           <span
               id="checkingStatusLabel"
               hidden$="[[!_isState(knownLatestState, 'checking')]]">
@@ -297,6 +285,18 @@
               id="cancelButton"
               class="action cancel"
               on-click="_cancelTapHandler">Cancel</gr-button>
+          <template is="dom-if" if="[[canBeStarted]]">
+            <!-- Use 'Send' here as the change may only about reviewers / ccs
+              and when this button is visible, the next button will always
+              be 'Start review' -->
+            <gr-button
+                link
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                has-tooltip
+                title="[[_saveTooltip]]"
+                on-click="_saveClickHandler">Send</gr-button>
+          </template>
           <gr-button
               id="sendButton"
               link
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 85e1f07..90f8540 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -43,7 +43,7 @@
   };
 
   const ButtonTooltips = {
-    SAVE: 'Save reply but do not send notification',
+    SAVE: 'Send but do not send notification or change review state',
     START_REVIEW: 'Mark as ready for review and send reply',
     SEND: 'Send reply',
   };
@@ -52,9 +52,24 @@
 
   const SEND_REPLY_TIMING_LABEL = 'SendReply';
 
-  Polymer({
-    is: 'gr-reply-dialog',
-
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrReplyDialog extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-reply-dialog'; }
     /**
      * Fired when a reply is successfully sent.
      *
@@ -93,156 +108,161 @@
      * @event send-disabled-changed
      */
 
-    properties: {
+    constructor() {
+      super();
+      this.FocusTarget = FocusTarget;
+    }
+
+    static get properties() {
+      return {
       /**
        * @type {{ _number: number, removable_reviewers: Array }}
        */
-      change: Object,
-      patchNum: String,
-      canBeStarted: {
-        type: Boolean,
-        value: false,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: String,
-        value: '',
-        observer: '_draftChanged',
-      },
-      quote: {
-        type: String,
-        value: '',
-      },
-      diffDrafts: {
-        type: Object,
-        observer: '_handleHeightChanged',
-      },
-      /** @type {!Function} */
-      filterReviewerSuggestion: {
-        type: Function,
-        value() {
-          return this._filterReviewerSuggestionGenerator(false);
+        change: Object,
+        patchNum: String,
+        canBeStarted: {
+          type: Boolean,
+          value: false,
         },
-      },
-      /** @type {!Function} */
-      filterCCSuggestion: {
-        type: Function,
-        value() {
-          return this._filterReviewerSuggestionGenerator(true);
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
         },
-      },
-      permittedLabels: Object,
-      /**
-       * @type {{ commentlinks: Array }}
-       */
-      projectConfig: Object,
-      knownLatestState: String,
-      underReview: {
-        type: Boolean,
-        value: true,
-      },
-
-      _account: Object,
-      _ccs: Array,
-      /** @type {?Object} */
-      _ccPendingConfirmation: {
-        type: Object,
-        observer: '_reviewerPendingConfirmationUpdated',
-      },
-      _messagePlaceholder: {
-        type: String,
-        computed: '_computeMessagePlaceholder(canBeStarted)',
-      },
-      _owner: Object,
-      /** @type {?} */
-      _pendingConfirmationDetails: Object,
-      _includeComments: {
-        type: Boolean,
-        value: true,
-      },
-      _reviewers: Array,
-      /** @type {?Object} */
-      _reviewerPendingConfirmation: {
-        type: Object,
-        observer: '_reviewerPendingConfirmationUpdated',
-      },
-      _previewFormatting: {
-        type: Boolean,
-        value: false,
-        observer: '_handleHeightChanged',
-      },
-      _reviewersPendingRemove: {
-        type: Object,
-        value: {
-          CC: [],
-          REVIEWER: [],
+        draft: {
+          type: String,
+          value: '',
+          observer: '_draftChanged',
         },
-      },
-      _sendButtonLabel: {
-        type: String,
-        computed: '_computeSendButtonLabel(canBeStarted)',
-      },
-      _savingComments: Boolean,
-      _reviewersMutated: {
-        type: Boolean,
-        value: false,
-      },
-      _labelsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _saveTooltip: {
-        type: String,
-        value: ButtonTooltips.SAVE,
-        readOnly: true,
-      },
-      _pluginMessage: {
-        type: String,
-        value: '',
-      },
-      _sendDisabled: {
-        type: Boolean,
-        computed: '_computeSendButtonDisabled(_sendButtonLabel, diffDrafts, ' +
-            'draft, _reviewersMutated, _labelsChanged, _includeComments, ' +
-            'disabled)',
-        observer: '_sendDisabledChanged',
-      },
-    },
+        quote: {
+          type: String,
+          value: '',
+        },
+        diffDrafts: {
+          type: Object,
+          observer: '_handleHeightChanged',
+        },
+        /** @type {!Function} */
+        filterReviewerSuggestion: {
+          type: Function,
+          value() {
+            return this._filterReviewerSuggestionGenerator(false);
+          },
+        },
+        /** @type {!Function} */
+        filterCCSuggestion: {
+          type: Function,
+          value() {
+            return this._filterReviewerSuggestionGenerator(true);
+          },
+        },
+        permittedLabels: Object,
+        /**
+         * @type {{ commentlinks: Array }}
+         */
+        projectConfig: Object,
+        knownLatestState: String,
+        underReview: {
+          type: Boolean,
+          value: true,
+        },
 
-    FocusTarget,
+        _account: Object,
+        _ccs: Array,
+        /** @type {?Object} */
+        _ccPendingConfirmation: {
+          type: Object,
+          observer: '_reviewerPendingConfirmationUpdated',
+        },
+        _messagePlaceholder: {
+          type: String,
+          computed: '_computeMessagePlaceholder(canBeStarted)',
+        },
+        _owner: Object,
+        /** @type {?} */
+        _pendingConfirmationDetails: Object,
+        _includeComments: {
+          type: Boolean,
+          value: true,
+        },
+        _reviewers: Array,
+        /** @type {?Object} */
+        _reviewerPendingConfirmation: {
+          type: Object,
+          observer: '_reviewerPendingConfirmationUpdated',
+        },
+        _previewFormatting: {
+          type: Boolean,
+          value: false,
+          observer: '_handleHeightChanged',
+        },
+        _reviewersPendingRemove: {
+          type: Object,
+          value: {
+            CC: [],
+            REVIEWER: [],
+          },
+        },
+        _sendButtonLabel: {
+          type: String,
+          computed: '_computeSendButtonLabel(canBeStarted)',
+        },
+        _savingComments: Boolean,
+        _reviewersMutated: {
+          type: Boolean,
+          value: false,
+        },
+        _labelsChanged: {
+          type: Boolean,
+          value: false,
+        },
+        _saveTooltip: {
+          type: String,
+          value: ButtonTooltips.SAVE,
+          readOnly: true,
+        },
+        _pluginMessage: {
+          type: String,
+          value: '',
+        },
+        _sendDisabled: {
+          type: Boolean,
+          computed: '_computeSendButtonDisabled(_sendButtonLabel, ' +
+            'diffDrafts, draft, _reviewersMutated, _labelsChanged, ' +
+            '_includeComments, disabled)',
+          observer: '_sendDisabledChanged',
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+    get keyBindings() {
+      return {
+        'esc': '_handleEscKey',
+        'ctrl+enter meta+enter': '_handleEnterKey',
+      };
+    }
 
-    keyBindings: {
-      'esc': '_handleEscKey',
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    },
+    static get observers() {
+      return [
+        '_changeUpdated(change.reviewers.*, change.owner)',
+        '_ccsChanged(_ccs.splices)',
+        '_reviewersChanged(_reviewers.splices)',
+      ];
+    }
 
-    observers: [
-      '_changeUpdated(change.reviewers.*, change.owner)',
-      '_ccsChanged(_ccs.splices)',
-      '_reviewersChanged(_reviewers.splices)',
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._getAccount().then(account => {
         this._account = account || {};
       });
-    },
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
-    },
+    }
 
     open(opt_focusTarget) {
       this.knownLatestState = LatestPatchState.CHECKING;
@@ -268,11 +288,11 @@
           this._savingComments = false;
         });
       }
-    },
+    }
 
     focus() {
       this._focusOn(FocusTarget.ANY);
-    },
+    }
 
     getFocusStops() {
       const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
@@ -280,14 +300,14 @@
         start: this.$.reviewers.focusStart,
         end,
       };
-    },
+    }
 
     setLabelValue(label, value) {
       const selectorEl =
           this.$.labelScores.$$(`gr-label-score-row[name="${label}"]`);
       if (!selectorEl) { return; }
       selectorEl.setSelectedValue(value);
-    },
+    }
 
     getLabelValue(label) {
       const selectorEl =
@@ -295,23 +315,23 @@
       if (!selectorEl) { return null; }
 
       return selectorEl.selectedValue;
-    },
+    }
 
     _handleEscKey(e) {
       this.cancel();
-    },
+    }
 
     _handleEnterKey(e) {
       this._submit();
-    },
+    }
 
     _ccsChanged(splices) {
       this._reviewerTypeChanged(splices, ReviewerTypes.CC);
-    },
+    }
 
     _reviewersChanged(splices) {
       this._reviewerTypeChanged(splices, ReviewerTypes.REVIEWER);
-    },
+    }
 
     _reviewerTypeChanged(splices, reviewerType) {
       if (splices && splices.indexSplices) {
@@ -342,7 +362,7 @@
           }
         }
       }
-    },
+    }
 
     _processReviewerChange(indexSplices, type) {
       for (const splice of indexSplices) {
@@ -354,7 +374,7 @@
           this._reviewersPendingRemove[type].push(account);
         }
       }
-    },
+    }
 
     /**
      * Resets the state of the _reviewersPendingRemove object, and removes
@@ -380,7 +400,7 @@
           this._reviewersPendingRemove[type] = [];
         }
       }
-    },
+    }
 
     /**
      * Removes an account from the change, both on the backend and the client.
@@ -404,7 +424,7 @@
           }
         }
       });
-    },
+    }
 
     _mapReviewer(reviewer) {
       let reviewerId;
@@ -416,7 +436,7 @@
         confirmed = reviewer.group.confirmed;
       }
       return {reviewer: reviewerId, confirmed};
-    },
+    }
 
     send(includeComments, startReview) {
       this.$.reporting.time(SEND_REPLY_TIMING_LABEL);
@@ -457,29 +477,32 @@
       this.disabled = true;
 
       const errFn = this._handle400Error.bind(this);
-      return this._saveReview(obj, errFn).then(response => {
-        if (!response) {
-          // Null or undefined response indicates that an error handler
-          // took responsibility, so just return.
-          return {};
-        }
-        if (!response.ok) {
-          this.fire('server-error', {response});
-          return {};
-        }
+      return this._saveReview(obj, errFn)
+          .then(response => {
+            if (!response) {
+              // Null or undefined response indicates that an error handler
+              // took responsibility, so just return.
+              return {};
+            }
+            if (!response.ok) {
+              this.fire('server-error', {response});
+              return {};
+            }
 
-        this.draft = '';
-        this._includeComments = true;
-        this.fire('send', null, {bubbles: false});
-        return accountAdditions;
-      }).then(result => {
-        this.disabled = false;
-        return result;
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-    },
+            this.draft = '';
+            this._includeComments = true;
+            this.fire('send', null, {bubbles: false});
+            return accountAdditions;
+          })
+          .then(result => {
+            this.disabled = false;
+            return result;
+          })
+          .catch(err => {
+            this.disabled = false;
+            throw err;
+          });
+    }
 
     _focusOn(section) {
       // Safeguard- always want to focus on something.
@@ -497,7 +520,7 @@
         const ccEntry = this.$.ccs.focusStart;
         ccEntry.async(ccEntry.focus);
       }
-    },
+    }
 
     _chooseFocusTarget() {
       // If we are the owner and the reviewers field is empty, focus on that.
@@ -509,7 +532,7 @@
 
       // Default to BODY.
       return FocusTarget.BODY;
-    },
+    }
 
     _handle400Error(response) {
       // A call to _saveReview could fail with a server error if erroneous
@@ -551,11 +574,11 @@
         this.fire('server-error', {response});
         return null; // Means that the error has been handled.
       });
-    },
+    }
 
     _computeHideDraftList(drafts) {
       return Object.keys(drafts || {}).length == 0;
-    },
+    }
 
     _computeDraftsTitle(drafts) {
       let total = 0;
@@ -567,13 +590,13 @@
       if (total == 0) { return ''; }
       if (total == 1) { return '1 Draft'; }
       if (total > 1) { return total + ' Drafts'; }
-    },
+    }
 
     _computeMessagePlaceholder(canBeStarted) {
       return canBeStarted ?
         'Add a note for your reviewers...' :
         'Say something nice...';
-    },
+    }
 
     _changeUpdated(changeRecord, owner) {
       // Polymer 2: check for undefined
@@ -582,7 +605,7 @@
       }
 
       this._rebuildReviewerArrays(changeRecord.base, owner);
-    },
+    }
 
     _rebuildReviewerArrays(change, owner) {
       this._owner = owner;
@@ -614,11 +637,11 @@
 
       this._ccs = ccs;
       this._reviewers = reviewers;
-    },
+    }
 
     _accountOrGroupKey(entry) {
       return entry.id || entry._account_id;
-    },
+    }
 
     /**
      * Generates a function to filter out reviewer/CC entries. When isCCs is
@@ -651,25 +674,25 @@
         }
         return this._reviewers.find(finder) === undefined;
       };
-    },
+    }
 
     _getAccount() {
       return this.$.restAPI.getAccount();
-    },
+    }
 
     _cancelTapHandler(e) {
       e.preventDefault();
       this.cancel();
-    },
+    }
 
     cancel() {
       this.fire('cancel', null, {bubbles: false});
       this.$.textarea.closeDropdown();
       this._purgeReviewersPendingRemove(true);
       this._rebuildReviewerArrays(this.change.reviewers, this._owner);
-    },
+    }
 
-    _saveTapHandler(e) {
+    _saveClickHandler(e) {
       e.preventDefault();
       if (!this.$.ccs.submitEntryText()) {
         // Do not proceed with the save if there is an invalid email entry in
@@ -679,12 +702,12 @@
       this.send(this._includeComments, false).then(keepReviewers => {
         this._purgeReviewersPendingRemove(false, keepReviewers);
       });
-    },
+    }
 
     _sendTapHandler(e) {
       e.preventDefault();
       this._submit();
-    },
+    }
 
     _submit() {
       if (!this.$.ccs.submitEntryText()) {
@@ -711,12 +734,12 @@
               detail: {message: `Error submitting review ${err}`},
             }));
           });
-    },
+    }
 
     _saveReview(review, opt_errFn) {
       return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
           review, opt_errFn);
-    },
+    }
 
     _reviewerPendingConfirmationUpdated(reviewer) {
       if (reviewer === null) {
@@ -726,7 +749,7 @@
             this._ccPendingConfirmation || this._reviewerPendingConfirmation;
         this.$.reviewerConfirmationOverlay.open();
       }
-    },
+    }
 
     _confirmPendingReviewer() {
       if (this._ccPendingConfirmation) {
@@ -736,7 +759,7 @@
         this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
         this._focusOn(FocusTarget.REVIEWERS);
       }
-    },
+    }
 
     _cancelPendingReviewer() {
       this._ccPendingConfirmation = null;
@@ -745,7 +768,7 @@
       const target =
           this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
       this._focusOn(target);
-    },
+    }
 
     _getStorageLocation() {
       // Tests trigger this method without setting change.
@@ -755,12 +778,12 @@
         patchNum: '@change',
         path: '@change',
       };
-    },
+    }
 
     _loadStoredDraft() {
       const draft = this.$.storage.getDraftComment(this._getStorageLocation());
       return draft ? draft.message : '';
-    },
+    }
 
     _handleAccountTextEntry() {
       // When either of the account entries has input added to the autocomplete,
@@ -768,7 +791,7 @@
       //
       // Note: if the text is removed, the save button will not get disabled.
       this._reviewersMutated = true;
-    },
+    }
 
     _draftChanged(newDraft, oldDraft) {
       this.debounce('store', () => {
@@ -781,37 +804,37 @@
               this.draft);
         }
       }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    },
+    }
 
     _handleHeightChanged(e) {
       this.fire('autogrow');
-    },
+    }
 
     _handleLabelsChanged() {
       this._labelsChanged = Object.keys(
           this.$.labelScores.getLabelValues()).length !== 0;
-    },
+    }
 
     _isState(knownLatestState, value) {
       return knownLatestState === value;
-    },
+    }
 
     _reload() {
       // Load the current change without any patch range.
       location.href = this.getBaseUrl() + '/c/' + this.change._number;
-    },
+    }
 
     _computeSendButtonLabel(canBeStarted) {
       return canBeStarted ? ButtonLabels.START_REVIEW : ButtonLabels.SEND;
-    },
+    }
 
     _computeSendButtonTooltip(canBeStarted) {
       return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
-    },
+    }
 
     _computeSavingLabelClass(savingComments) {
       return savingComments ? 'saving' : '';
-    },
+    }
 
     _computeSendButtonDisabled(buttonLabel, drafts, text, reviewersMutated,
         labelsChanged, includeComments, disabled) {
@@ -832,7 +855,7 @@
       if (buttonLabel === ButtonLabels.START_REVIEW) { return false; }
       const hasDrafts = includeComments && Object.keys(drafts).length;
       return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
-    },
+    }
 
     _computePatchSetWarning(patchNum, labelsChanged) {
       let str = `Patch ${patchNum} is not latest.`;
@@ -840,28 +863,30 @@
         str += ' Voting on a non-latest patch will have no effect.';
       }
       return str;
-    },
+    }
 
     setPluginMessage(message) {
       this._pluginMessage = message;
-    },
+    }
 
     _sendDisabledChanged(sendDisabled) {
       this.dispatchEvent(new CustomEvent('send-disabled-changed'));
-    },
+    }
 
     _getReviewerSuggestionsProvider(change) {
       const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
           change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
       provider.init();
       return provider;
-    },
+    }
 
     _getCcSuggestionsProvider(change) {
       const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
           change._number, Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
       provider.init();
       return provider;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrReplyDialog.is, GrReplyDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index d8d49cf..4c6c5bd 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reply-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -136,23 +136,24 @@
     });
 
     function stubSaveReview(jsonResponseProducer) {
-      return sandbox.stub(element, '_saveReview', review => {
-        return new Promise((resolve, reject) => {
-          try {
-            const result = jsonResponseProducer(review) || {};
-            const resultStr =
-                element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
-            resolve({
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
-          } catch (err) {
-            reject(err);
-          }
-        });
-      });
+      return sandbox.stub(
+          element,
+          '_saveReview',
+          review => new Promise((resolve, reject) => {
+            try {
+              const result = jsonResponseProducer(review) || {};
+              const resultStr =
+              element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+              resolve({
+                ok: true,
+                text() {
+                  return Promise.resolve(resultStr);
+                },
+              });
+            } catch (err) {
+              reject(err);
+            }
+          }));
     }
 
     test('default to publishing drafts with reply', done => {
@@ -187,7 +188,7 @@
     });
 
     test('keep drafts with reply', done => {
-      MockInteractions.tap(element.$$('#includeComments'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
       assert.equal(element._includeComments, false);
 
       // Async tick is needed because iron-selector content is distributed and
@@ -271,7 +272,8 @@
     test('getlabelValue when no score is selected', done => {
       flush(() => {
         element.$$('gr-label-scores')
-            .$$(`gr-label-score-row[name="Code-Review"]`).setSelectedValue(-1);
+            .$$(`gr-label-score-row[name="Code-Review"]`)
+            .setSelectedValue(-1);
         assert.strictEqual(element.getLabelValue('Verified'), ' 0');
         done();
       });
@@ -372,73 +374,87 @@
             element._pendingConfirmationDetails);
       }
 
-      observer.then(() => {
-        assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-        observer = overlayObserver('closed');
-        const expected = 'Group name has 10 members';
-        assert.notEqual(
-            element.$.reviewerConfirmationOverlay.innerText.indexOf(expected),
-            -1);
-        MockInteractions.tap(noButton); // close the overlay
-        return observer;
-      }).then(() => {
-        assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+      observer
+          .then(() => {
+            assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+            observer = overlayObserver('closed');
+            const expected = 'Group name has 10 members';
+            assert.notEqual(
+                element.$.reviewerConfirmationOverlay.innerText
+                    .indexOf(expected),
+                -1);
+            MockInteractions.tap(noButton); // close the overlay
+            return observer;
+          }).then(() => {
+            assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
-        // We should be focused on account entry input.
-        assert.isTrue(
-            isFocusInsideElement(element.$.reviewers.$.entry.$.input.$.input));
+            // We should be focused on account entry input.
+            assert.isTrue(
+                isFocusInsideElement(
+                    element.$.reviewers.$.entry.$.input.$.input
+                )
+            );
 
-        // No reviewer/CC should have been added.
-        assert.equal(element.$.ccs.additions().length, 0);
-        assert.equal(element.$.reviewers.additions().length, 0);
+            // No reviewer/CC should have been added.
+            assert.equal(element.$.ccs.additions().length, 0);
+            assert.equal(element.$.reviewers.additions().length, 0);
 
-        // Reopen confirmation dialog.
-        observer = overlayObserver('opened');
-        if (cc) {
-          element._ccPendingConfirmation = {
-            group,
-            count: 10,
-          };
-        } else {
-          element._reviewerPendingConfirmation = {
-            group,
-            count: 10,
-          };
-        }
-        return observer;
-      }).then(() => {
-        assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-        observer = overlayObserver('closed');
-        MockInteractions.tap(yesButton); // Confirm the group.
-        return observer;
-      }).then(() => {
-        assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-        const additions = cc ?
-          element.$.ccs.additions() :
-          element.$.reviewers.additions();
-        assert.deepEqual(
-            additions,
-            [
-              {
-                group: {
-                  id: 'id',
-                  name: 'name',
-                  confirmed: true,
-                  _group: true,
-                  _pendingAdd: true,
-                },
-              },
-            ]);
+            // Reopen confirmation dialog.
+            observer = overlayObserver('opened');
+            if (cc) {
+              element._ccPendingConfirmation = {
+                group,
+                count: 10,
+              };
+            } else {
+              element._reviewerPendingConfirmation = {
+                group,
+                count: 10,
+              };
+            }
+            return observer;
+          })
+          .then(() => {
+            assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+            observer = overlayObserver('closed');
+            MockInteractions.tap(yesButton); // Confirm the group.
+            return observer;
+          })
+          .then(() => {
+            assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+            const additions = cc ?
+              element.$.ccs.additions() :
+              element.$.reviewers.additions();
+            assert.deepEqual(
+                additions,
+                [
+                  {
+                    group: {
+                      id: 'id',
+                      name: 'name',
+                      confirmed: true,
+                      _group: true,
+                      _pendingAdd: true,
+                    },
+                  },
+                ]);
 
-        // We should be focused on account entry input.
-        if (cc) {
-          assert.isTrue(
-              isFocusInsideElement(element.$.ccs.$.entry.$.input.$.input));
-        } else {
-          assert.isTrue(
-              isFocusInsideElement(element.$.reviewers.$.entry.$.input.$.input));
-        }
-      }).then(done);
+            // We should be focused on account entry input.
+            if (cc) {
+              assert.isTrue(
+                  isFocusInsideElement(
+                      element.$.ccs.$.entry.$.input.$.input
+                  )
+              );
+            } else {
+              assert.isTrue(
+                  isFocusInsideElement(
+                      element.$.reviewers.$.entry.$.input.$.input
+                  )
+              );
+            }
+          })
+          .then(done);
     }
 
     test('cc confirmation', done => {
@@ -460,7 +476,8 @@
       flushAsynchronousOperations();
       assert.isFalse(element._reviewersMutated);
       assert.isTrue(element.$.ccs.allowAnyInput);
-      assert.isFalse(element.$$('#reviewers').allowAnyInput);
+      assert.isFalse(element.shadowRoot
+          .querySelector('#reviewers').allowAnyInput);
       element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
           {bubbles: true, composed: true}));
       assert.isTrue(element._reviewersMutated);
@@ -672,7 +689,8 @@
         // fail.
 
         element.$$('gr-label-scores').$$(
-            'gr-label-score-row[name="Verified"]').setSelectedValue(-1);
+            'gr-label-score-row[name="Verified"]')
+            .setSelectedValue(-1);
         MockInteractions.tap(element.$$('.send'));
       });
     });
@@ -966,9 +984,10 @@
       let startReviewStub;
 
       setup(() => {
-        startReviewStub = sandbox.stub(element.$.restAPI, 'startReview', () => {
-          return Promise.resolve();
-        });
+        startReviewStub = sandbox.stub(
+            element.$.restAPI,
+            'startReview',
+            () => Promise.resolve());
       });
 
       test('ready property in review input on start review', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index a5875ab1..132ce11 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -32,7 +32,15 @@
         opacity: .8;
         pointer-events: none;
       }
-      .container > :not(:first-child) {
+      .container {
+        display: block;
+        /* This is a bit of a hack. We tried to use margin-top with
+           :not(:first-child) before, but :first-child does not understand
+           whether a child is visible or not. So adding a margin for every
+           child and then a negative one at the top does the trick. */
+        margin-top: calc(0px - var(--spacing-s));
+      }
+      .container > * {
         margin-top: var(--spacing-s);
       }
       gr-button {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index c94625d..ddc6275 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -17,70 +17,76 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-reviewer-list',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrReviewerList extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-reviewer-list'; }
     /**
      * Fired when the "Add reviewer..." button is tapped.
      *
      * @event show-reply-dialog
      */
 
-    properties: {
-      change: Object,
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      mutable: {
-        type: Boolean,
-        value: false,
-      },
-      reviewersOnly: {
-        type: Boolean,
-        value: false,
-      },
-      ccsOnly: {
-        type: Boolean,
-        value: false,
-      },
-      maxReviewersDisplayed: Number,
+    static get properties() {
+      return {
+        change: Object,
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        mutable: {
+          type: Boolean,
+          value: false,
+        },
+        reviewersOnly: {
+          type: Boolean,
+          value: false,
+        },
+        ccsOnly: {
+          type: Boolean,
+          value: false,
+        },
+        maxReviewersDisplayed: Number,
 
-      _displayedReviewers: {
-        type: Array,
-        value() { return []; },
-      },
-      _reviewers: {
-        type: Array,
-        value() { return []; },
-      },
-      _showInput: {
-        type: Boolean,
-        value: false,
-      },
-      _addLabel: {
-        type: String,
-        computed: '_computeAddLabel(ccsOnly)',
-      },
-      _hiddenReviewerCount: {
-        type: Number,
-        computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
-      },
+        _displayedReviewers: {
+          type: Array,
+          value() { return []; },
+        },
+        _reviewers: {
+          type: Array,
+          value() { return []; },
+        },
+        _showInput: {
+          type: Boolean,
+          value: false,
+        },
+        _addLabel: {
+          type: String,
+          computed: '_computeAddLabel(ccsOnly)',
+        },
+        _hiddenReviewerCount: {
+          type: Number,
+          computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
+        },
 
+        // Used for testing.
+        _lastAutocompleteRequest: Object,
+        _xhrPromise: Object,
+      };
+    }
 
-      // Used for testing.
-      _lastAutocompleteRequest: Object,
-      _xhrPromise: Object,
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
-      '_reviewersChanged(change.reviewers.*, change.owner)',
-    ],
+    static get observers() {
+      return [
+        '_reviewersChanged(change.reviewers.*, change.owner)',
+      ];
+    }
 
     /**
      * Converts change.permitted_labels to an array of hashes of label keys to
@@ -97,11 +103,13 @@
      */
     _permittedLabelsToNumericScores(labels) {
       if (!labels) return [];
-      return Object.keys(labels).map(label => ({
-        label,
-        scores: labels[label].map(v => parseInt(v, 10)),
-      }));
-    },
+      return Object.keys(labels).map(label => {
+        return {
+          label,
+          scores: labels[label].map(v => parseInt(v, 10)),
+        };
+      });
+    }
 
     /**
      * Returns hash of labels to max permitted score.
@@ -111,12 +119,14 @@
      */
     _getMaxPermittedScores(change) {
       return this._permittedLabelsToNumericScores(change.permitted_labels)
-          .map(({label, scores}) => ({
-            [label]: scores
-                .map(v => parseInt(v, 10))
-                .reduce((a, b) => Math.max(a, b))}))
+          .map(({label, scores}) => {
+            return {
+              [label]: scores
+                  .map(v => parseInt(v, 10))
+                  .reduce((a, b) => Math.max(a, b))};
+          })
           .reduce((acc, i) => Object.assign(acc, i), {});
-    },
+    }
 
     /**
      * Returns max permitted score for reviewer.
@@ -142,7 +152,7 @@
         return 0;
       }
       return NaN;
-    },
+    }
 
     _computeReviewerTooltip(reviewer, change) {
       if (!change || !change.labels) { return ''; }
@@ -163,7 +173,7 @@
       } else {
         return '';
       }
-    },
+    }
 
     _reviewersChanged(changeRecord, owner) {
       // Polymer 2: check for undefined
@@ -184,9 +194,8 @@
           result = result.concat(reviewers[key]);
         }
       }
-      this._reviewers = result.filter(reviewer => {
-        return reviewer._account_id != owner._account_id;
-      });
+      this._reviewers = result
+          .filter(reviewer => reviewer._account_id != owner._account_id);
 
       // If there is one or two more than the max reviewers, don't show the
       // 'show more' button, because it takes up just as much space.
@@ -197,7 +206,7 @@
       } else {
         this._displayedReviewers = this._reviewers;
       }
-    },
+    }
 
     _computeHiddenCount(reviewers, displayedReviewers) {
       // Polymer 2: check for undefined
@@ -206,7 +215,7 @@
       }
 
       return reviewers.length - displayedReviewers.length;
-    },
+    }
 
     _computeCanRemoveReviewer(reviewer, mutable) {
       if (!mutable) { return false; }
@@ -220,7 +229,7 @@
         }
       }
       return false;
-    },
+    }
 
     _handleRemove(e) {
       e.preventDefault();
@@ -244,11 +253,12 @@
             }
           }
         }
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-    },
+      })
+          .catch(err => {
+            this.disabled = false;
+            throw err;
+          });
+    }
 
     _handleAddTap(e) {
       e.preventDefault();
@@ -260,18 +270,20 @@
         value.ccsOnly = true;
       }
       this.fire('show-reply-dialog', {value});
-    },
+    }
 
     _handleViewAll(e) {
       this._displayedReviewers = this._reviewers;
-    },
+    }
 
     _removeReviewer(id) {
       return this.$.restAPI.removeChangeReviewer(this.change._number, id);
-    },
+    }
 
     _computeAddLabel(ccsOnly) {
       return ccsOnly ? 'Add CC' : 'Add reviewer';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrReviewerList.is, GrReviewerList);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 6936a04..18f7bc5 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -237,7 +237,6 @@
       assert.isFalse(element.$$('.hiddenReviewers').hidden);
     });
 
-
     test('no maxReviewersDisplayed', () => {
       const reviewers = [];
       for (let i = 0; i < 7; i++) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index 747a47a..9e42de1 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -21,40 +21,44 @@
    * Fired when a comment is saved or deleted
    *
    * @event thread-list-modified
+   * @extends Polymer.Element
    */
+  class GrThreadList extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-thread-list'; }
 
-  Polymer({
-    is: 'gr-thread-list',
-
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      change: Object,
-      threads: Array,
-      changeNum: String,
-      loggedIn: Boolean,
-      _sortedThreads: {
-        type: Array,
-      },
-      _filteredThreads: {
-        type: Array,
-        computed: '_computeFilteredThreads(_sortedThreads, _unresolvedOnly, ' +
-            '_draftsOnly)',
-      },
-      _unresolvedOnly: {
-        type: Boolean,
-        value: false,
-      },
-      _draftsOnly: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        change: Object,
+        threads: Array,
+        changeNum: String,
+        loggedIn: Boolean,
+        _sortedThreads: {
+          type: Array,
+        },
+        _filteredThreads: {
+          type: Array,
+          computed: '_computeFilteredThreads(_sortedThreads, ' +
+            '_unresolvedOnly, _draftsOnly)',
+        },
+        _unresolvedOnly: {
+          type: Boolean,
+          value: false,
+        },
+        _draftsOnly: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    observers: ['_computeSortedThreads(threads.*)'],
+    static get observers() { return ['_computeSortedThreads(threads.*)']; }
 
     _computeShowDraftToggle(loggedIn) {
       return loggedIn ? 'show' : '';
-    },
+    }
 
     /**
      * Order as follows:
@@ -69,7 +73,7 @@
       const threads = changeRecord.base;
       if (!threads) { return []; }
       this._updateSortedThreads(threads);
-    },
+    }
 
     _updateSortedThreads(threads) {
       this._sortedThreads =
@@ -91,7 +95,7 @@
             }
             return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
           });
-    },
+    }
 
     _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly) {
       // Polymer 2: check for undefined
@@ -126,7 +130,7 @@
           return c;
         }
       }).map(threadInfo => threadInfo.thread);
-    },
+    }
 
     _getThreadWithSortInfo(thread) {
       const lastComment = thread.comments[thread.comments.length - 1] || {};
@@ -144,7 +148,7 @@
         hasDraft: !!lastComment.__draft,
         updated: lastComment.updated,
       };
-    },
+    }
 
     removeThread(rootId) {
       for (let i = 0; i < this.threads.length; i++) {
@@ -155,11 +159,11 @@
           return;
         }
       }
-    },
+    }
 
     _handleThreadDiscard(e) {
       this.removeThread(e.detail.rootId);
-    },
+    }
 
     _handleCommentsChanged(e) {
       // Reset threads so thread computations occur on deep array changes to
@@ -168,10 +172,12 @@
 
       this.dispatchEvent(new CustomEvent('thread-list-modified',
           {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
-    },
+    }
 
     _isOnParent(side) {
       return !!side;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrThreadList.is, GrThreadList);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index ff65aa8..b29246d 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-thread-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -290,7 +290,6 @@
       assert.equal(element._filteredThreads.includes(thread), true);
     });
 
-
     test('thread removal', () => {
       threadElements[1].fire('thread-discard', {rootId: 'rc2'});
       flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
index 092204a..60cbd42 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -27,58 +27,67 @@
     'pull',
   ];
 
-  Polymer({
-    is: 'gr-upload-help-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrUploadHelpDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-upload-help-dialog'; }
     /**
      * Fired when the user presses the close button.
      *
      * @event close
      */
 
-    properties: {
-      revision: Object,
-      targetBranch: String,
-      _commitCommand: {
-        type: String,
-        value: COMMIT_COMMAND,
-        readOnly: true,
-      },
-      _fetchCommand: {
-        type: String,
-        computed: '_computeFetchCommand(revision, ' +
+    static get properties() {
+      return {
+        revision: Object,
+        targetBranch: String,
+        _commitCommand: {
+          type: String,
+          value: COMMIT_COMMAND,
+          readOnly: true,
+        },
+        _fetchCommand: {
+          type: String,
+          computed: '_computeFetchCommand(revision, ' +
             '_preferredDownloadCommand, _preferredDownloadScheme)',
-      },
-      _preferredDownloadCommand: String,
-      _preferredDownloadScheme: String,
-      _pushCommand: {
-        type: String,
-        computed: '_computePushCommand(targetBranch)',
-      },
-    },
+        },
+        _preferredDownloadCommand: String,
+        _preferredDownloadScheme: String,
+        _pushCommand: {
+          type: String,
+          computed: '_computePushCommand(targetBranch)',
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
+    /** @override */
     attached() {
-      this.$.restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          return this.$.restAPI.getPreferences();
-        }
-      }).then(prefs => {
-        if (prefs) {
-          this._preferredDownloadCommand = prefs.download_command;
-          this._preferredDownloadScheme = prefs.download_scheme;
-        }
-      });
-    },
+      super.attached();
+      this.$.restAPI.getLoggedIn()
+          .then(loggedIn => {
+            if (loggedIn) {
+              return this.$.restAPI.getPreferences();
+            }
+          })
+          .then(prefs => {
+            if (prefs) {
+              this._preferredDownloadCommand = prefs.download_command;
+              this._preferredDownloadScheme = prefs.download_scheme;
+            }
+          });
+    }
 
     _handleCloseTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('close', null, {bubbles: false});
-    },
+    }
 
     _computeFetchCommand(revision, preferredDownloadCommand,
         preferredDownloadScheme) {
@@ -126,10 +135,12 @@
       }
 
       return undefined;
-    },
+    }
 
     _computePushCommand(targetBranch) {
       return PUSH_COMMAND_PREFIX + targetBranch;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrUploadHelpDialog.is, GrUploadHelpDialog);
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
index 577b978..76377fb 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-upload-help-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 7cbe988..66c00f9 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -19,29 +19,41 @@
 
   const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
 
-  Polymer({
-    is: 'gr-account-dropdown',
+  /**
+   * @appliesMixin Gerrit.DisplayNameMixin
+   * @extends Polymer.Element
+   */
+  class GrAccountDropdown extends Polymer.mixinBehaviors( [
+    Gerrit.DisplayNameBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-account-dropdown'; }
 
-    properties: {
-      account: Object,
-      config: Object,
-      links: {
-        type: Array,
-        computed: '_getLinks(_switchAccountUrl, _path)',
-      },
-      topContent: {
-        type: Array,
-        computed: '_getTopContent(account)',
-      },
-      _path: {
-        type: String,
-        value: '/',
-      },
-      _hasAvatars: Boolean,
-      _switchAccountUrl: String,
-    },
+    static get properties() {
+      return {
+        account: Object,
+        config: Object,
+        links: {
+          type: Array,
+          computed: '_getLinks(_switchAccountUrl, _path)',
+        },
+        topContent: {
+          type: Array,
+          computed: '_getTopContent(account)',
+        },
+        _path: {
+          type: String,
+          value: '/',
+        },
+        _hasAvatars: Boolean,
+        _switchAccountUrl: String,
+      };
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._handleLocationChange();
       this.listen(window, 'location-change', '_handleLocationChange');
       this.$.restAPI.getConfig().then(cfg => {
@@ -54,15 +66,13 @@
         }
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
       });
-    },
+    }
 
-    behaviors: [
-      Gerrit.DisplayNameBehavior,
-    ],
-
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'location-change', '_handleLocationChange');
-    },
+    }
 
     _getLinks(switchAccountUrl, path) {
       // Polymer 2: check for undefined
@@ -78,30 +88,32 @@
       }
       links.push({name: 'Sign out', url: '/logout'});
       return links;
-    },
+    }
 
     _getTopContent(account) {
       return [
         {text: this._accountName(account), bold: true},
         {text: account.email ? account.email : ''},
       ];
-    },
+    }
 
     _handleLocationChange() {
       this._path =
           window.location.pathname +
           window.location.search +
           window.location.hash;
-    },
+    }
 
     _interpolateUrl(url, replacements) {
-      return url.replace(INTERPOLATE_URL_PATTERN, (match, p1) => {
-        return replacements[p1] || '';
-      });
-    },
+      return url.replace(
+          INTERPOLATE_URL_PATTERN,
+          (match, p1) => replacements[p1] || '');
+    }
 
     _accountName(account) {
       return this.getUserName(this.config, account, true);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccountDropdown.is, GrAccountDropdown);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index e29faa8..9dafb74 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-dropdown</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
index 8d3b58e..db70d57 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -17,21 +17,27 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-error-dialog',
-
+  /** @extends Polymer.Element */
+  class GrErrorDialog extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-error-dialog'; }
     /**
      * Fired when the dismiss button is pressed.
      *
      * @event dismiss
      */
 
-    properties: {
-      text: String,
-    },
+    static get properties() {
+      return {
+        text: String,
+      };
+    }
 
     _handleConfirm() {
       this.dispatchEvent(new CustomEvent('dismiss'));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrErrorDialog.is, GrErrorDialog);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
index 648f8be..dd4a71a 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 048d392..03d8d6a 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -23,6 +23,9 @@
 <link rel="import" href="../../shared/gr-alert/gr-alert.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<!-- Import to get Gerrit interface -->
+<!-- TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface -->
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-error-manager">
   <template>
@@ -33,6 +36,13 @@
           confirm-label="Dismiss"
           confirm-on-enter></gr-error-dialog>
     </gr-overlay>
+    <gr-overlay
+      id="noInteractionOverlay"
+      with-backdrop
+      always-on-top
+      no-cancel-on-esc-key
+      no-cancel-on-outside-click>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
   </template>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 5865e3c..5a7e58d 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -25,95 +25,130 @@
   const TOO_MANY_FILES = 'too many files to find conflicts';
   const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
-  Polymer({
-    is: 'gr-error-manager',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrErrorManager extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-error-manager'; }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-    ],
-
-    properties: {
+    static get properties() {
+      return {
       /**
        * The ID of the account that was logged in when the app was launched. If
        * not set, then there was no account at launch.
        */
-      knownAccountId: Number,
+        knownAccountId: Number,
 
-      /** @type {?Object} */
-      _alertElement: Object,
-      /** @type {?number} */
-      _hideAlertHandle: Number,
-      _refreshingCredentials: {
-        type: Boolean,
-        value: false,
-      },
+        /** @type {?Object} */
+        _alertElement: Object,
+        /** @type {?number} */
+        _hideAlertHandle: Number,
+        _refreshingCredentials: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * The time (in milliseconds) since the most recent credential check.
-       */
-      _lastCredentialCheck: {
-        type: Number,
-        value() { return Date.now(); },
-      },
-    },
+        /**
+         * The time (in milliseconds) since the most recent credential check.
+         */
+        _lastCredentialCheck: {
+          type: Number,
+          value() { return Date.now(); },
+        },
+      };
+    }
 
+    constructor() {
+      super();
+
+      /** @type {!Gerrit.Auth} */
+      this._authService = Gerrit.Auth;
+
+      /** @type {?Function} */
+      this._authErrorHandlerDeregistrationHook;
+    }
+
+    /** @override */
     attached() {
+      super.attached();
       this.listen(document, 'server-error', '_handleServerError');
       this.listen(document, 'network-error', '_handleNetworkError');
-      this.listen(document, 'auth-error', '_handleAuthError');
       this.listen(document, 'show-alert', '_handleShowAlert');
       this.listen(document, 'show-error', '_handleShowErrorDialog');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
       this.listen(document, 'show-auth-required', '_handleAuthRequired');
-    },
 
+      this._authErrorHandlerDeregistrationHook = Gerrit.on('auth-error',
+          event => {
+            this._handleAuthError(event.message, event.action);
+          });
+    }
+
+    /** @override */
     detached() {
+      super.detached();
       this._clearHideAlertHandle();
       this.unlisten(document, 'server-error', '_handleServerError');
       this.unlisten(document, 'network-error', '_handleNetworkError');
-      this.unlisten(document, 'auth-error', '_handleAuthError');
       this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
       this.unlisten(document, 'show-error', '_handleShowErrorDialog');
-    },
+
+      this._authErrorHandlerDeregistrationHook();
+    }
 
     _shouldSuppressError(msg) {
       return msg.includes(TOO_MANY_FILES);
-    },
+    }
 
     _handleAuthRequired() {
       this._showAuthErrorAlert(
           'Log in is required to perform that action.', 'Log in.');
-    },
+    }
 
-    _handleAuthError() {
-      this._showAuthErrorAlert('Auth error', 'Refresh credentials.');
-    },
+    _handleAuthError(msg, action) {
+      this.$.noInteractionOverlay.open().then(() => {
+        this._showAuthErrorAlert(msg, action);
+      });
+    }
 
     _handleServerError(e) {
       const {request, response} = e.detail;
-      Promise.all([response.text(), this._getLoggedIn()])
-          .then(([errorText, loggedIn]) => {
-            const url = request && (request.anonymizedUrl || request.url);
-            const {status, statusText} = response;
-            if (response.status === 403 &&
-                loggedIn &&
-                errorText === AUTHENTICATION_REQUIRED) {
-              // The app was logged at one point and is now getting auth errors.
-              // This indicates the auth token is no longer valid.
-              this._handleAuthError();
-            } else if (!this._shouldSuppressError(errorText)) {
-              this._showErrorDialog(this._constructServerErrorMsg({
-                status,
-                statusText,
-                errorText,
-                url,
-              }));
-            }
-            console.error(errorText);
-          });
-    },
+      response.text().then(errorText => {
+        const url = request && (request.anonymizedUrl || request.url);
+        const {status, statusText} = response;
+        if (response.status === 403
+                && !this._authService.isAuthed
+                && errorText === AUTHENTICATION_REQUIRED) {
+          // if not authed previously, this is trying to access auth required APIs
+          // show auth required alert
+          this._handleAuthRequired();
+        } else if (response.status === 403
+                && this._authService.isAuthed
+                && errorText === AUTHENTICATION_REQUIRED) {
+          // The app was logged at one point and is now getting auth errors.
+          // This indicates the auth token may no longer valid.
+          // Re-check on auth
+          this._authService.clearCache();
+          this.$.restAPI.getLoggedIn();
+        } else if (!this._shouldSuppressError(errorText)) {
+          this._showErrorDialog(this._constructServerErrorMsg({
+            status,
+            statusText,
+            errorText,
+            url,
+          }));
+        }
+        console.log(`server error: ${errorText}`);
+      });
+    }
 
     _constructServerErrorMsg({errorText, status, statusText, url}) {
       let err = `Error ${status}`;
@@ -122,21 +157,17 @@
       if (errorText) { err += errorText; }
       if (url) { err += `\nEndpoint: ${url}`; }
       return err;
-    },
+    }
 
     _handleShowAlert(e) {
       this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
           e.detail.dismissOnNavigation);
-    },
+    }
 
     _handleNetworkError(e) {
       this._showAlert('Server unavailable');
       console.error(e.detail.error.message);
-    },
-
-    _getLoggedIn() {
-      return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     /**
      * @param {string} text
@@ -147,6 +178,8 @@
     _showAlert(text, opt_actionText, opt_actionCallback,
         opt_dismissOnNavigation) {
       if (this._alertElement) {
+        // do not override auth alerts
+        if (this._alertElement.type === 'AUTH') return;
         this._hideAlert();
       }
 
@@ -161,7 +194,7 @@
       const el = this._createToastAlert();
       el.show(text, opt_actionText, opt_actionCallback);
       this._alertElement = el;
-    },
+    }
 
     _hideAlert() {
       if (!this._alertElement) { return; }
@@ -171,20 +204,24 @@
 
       // Remove listener for page navigation, if it exists.
       this.unlisten(document, 'location-change', '_hideAlert');
-    },
+    }
 
     _clearHideAlertHandle() {
       if (this._hideAlertHandle != null) {
         this.cancelAsync(this._hideAlertHandle);
         this._hideAlertHandle = null;
       }
-    },
+    }
 
     _showAuthErrorAlert(errorText, actionText) {
-      // TODO(viktard): close alert if it's not for auth error.
-      if (this._alertElement) { return; }
+      // hide any existing alert like `reload`
+      // as auth error should have the highest priority
+      if (this._alertElement) {
+        this._alertElement.hide();
+      }
 
       this._alertElement = this._createToastAlert();
+      this._alertElement.type = 'AUTH';
       this._alertElement.show(errorText, actionText,
           this._createLoginPopup.bind(this));
 
@@ -193,13 +230,13 @@
       if (!document.hidden) {
         this._handleVisibilityChange();
       }
-    },
+    }
 
     _createToastAlert() {
       const el = document.createElement('gr-alert');
       el.toast = true;
       return el;
-    },
+    }
 
     _handleVisibilityChange() {
       // Ignore when the page is transitioning to hidden (or hidden is
@@ -214,39 +251,57 @@
           this.knownAccountId !== undefined &&
           timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
         this._lastCredentialCheck = Date.now();
-        this.$.restAPI.checkCredentials();
+
+        // check auth status in case:
+        // - user signed out
+        // - user switched account
+        this._checkSignedIn();
       }
-    },
+    }
 
     _requestCheckLoggedIn() {
       this.debounce(
           'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
-    },
+    }
 
     _checkSignedIn() {
-      this.$.restAPI.checkCredentials().then(account => {
-        const isLoggedIn = !!account;
-        this._lastCredentialCheck = Date.now();
-        if (this._refreshingCredentials) {
-          if (isLoggedIn) {
-            // If the credentials were refreshed but the account is different
-            // then reload the page completely.
-            if (account._account_id !== this.knownAccountId) {
-              this._reloadPage();
-              return;
-            }
+      this._lastCredentialCheck = Date.now();
 
-            this._handleCredentialRefreshed();
-          } else {
-            this._requestCheckLoggedIn();
-          }
+      // force to refetch account info
+      this.$.restAPI.invalidateAccountsCache();
+      this._authService.clearCache();
+
+      this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+        // do nothing if its refreshing
+        if (!this._refreshingCredentials) return;
+
+        if (!isLoggedIn) {
+          // check later
+          // 1. guest mode
+          // 2. or signed out
+          // in case #2, auth-error is taken care of separately
+          this._requestCheckLoggedIn();
+        } else {
+          // check account
+          this.$.restAPI.getAccount().then(account => {
+            if (this._refreshingCredentials) {
+              // If the credentials were refreshed but the account is different
+              // then reload the page completely.
+              if (account._account_id !== this.knownAccountId) {
+                this._reloadPage();
+                return;
+              }
+
+              this._handleCredentialRefreshed();
+            }
+          });
         }
       });
-    },
+    }
 
     _reloadPage() {
       window.location.reload();
-    },
+    }
 
     _createLoginPopup() {
       const left = window.screenLeft +
@@ -262,31 +317,37 @@
       window.open(this.getBaseUrl() +
           '/login/%3FcloseAfterLogin', '_blank', options.join(','));
       this.listen(window, 'focus', '_handleWindowFocus');
-    },
+    }
 
     _handleCredentialRefreshed() {
       this.unlisten(window, 'focus', '_handleWindowFocus');
       this._refreshingCredentials = false;
       this._hideAlert();
       this._showAlert('Credentials refreshed.');
-    },
+      this.$.noInteractionOverlay.close();
+
+      // Clear the cache for auth
+      this._authService.clearCache();
+    }
 
     _handleWindowFocus() {
       this.flushDebouncer('checkLoggedIn');
-    },
+    }
 
     _handleShowErrorDialog(e) {
       this._showErrorDialog(e.detail.message);
-    },
+    }
 
     _handleDismissErrorDialog() {
       this.$.errorOverlay.close();
-    },
+    }
 
     _showErrorDialog(message) {
       this.$.reporting.reportErrorDialog(message);
       this.$.errorDialog.text = message;
       this.$.errorOverlay.open();
-    },
-  });
-})();
+    }
+  }
+
+  customElements.define(GrErrorManager.is, GrErrorManager);
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 9140c17..76d12e9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -18,15 +18,15 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-error-manager</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
 <script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../../test/common-test-setup.html" />
 <link rel="import" href="gr-error-manager.html">
 
-<script>void(0);</script>
+<script>void (0);</script>
 
 <test-fixture id="basic">
   <template>
@@ -41,285 +41,426 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-      });
-      element = fixture('basic');
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('does not show auth error on 403 by default', done => {
-      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-      const responseText = Promise.resolve('server says no.');
-      element.fire('server-error',
-          {response: {status: 403, text() { return responseText; }}}
-      );
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        responseText,
-      ]).then(() => {
-        assert.isFalse(showAuthErrorStub.calledOnce);
-        done();
+    suite('when authed', () => {
+      setup(() => {
+        sandbox.stub(window, 'fetch')
+            .returns(Promise.resolve({ok: true, status: 204}));
+        element = fixture('basic');
+        element._authService.clearCache();
       });
-    });
 
-    test('shows auth error on 403 and Authentication required', done => {
-      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-      const responseText = Promise.resolve('Authentication required\n');
-      element.fire('server-error',
-          {response: {status: 403, text() { return responseText; }}}
-      );
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        responseText,
-      ]).then(() => {
-        assert.isTrue(showAuthErrorStub.calledOnce);
-        done();
+      test('does not show auth error on 403 by default', done => {
+        const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+        const responseText = Promise.resolve('server says no.');
+        element.fire('server-error',
+            {response: {status: 403, text() { return responseText; }}}
+        );
+        flush(() => {
+          assert.isFalse(showAuthErrorStub.calledOnce);
+          done();
+        });
       });
-    });
 
-    test('show logged in error', () => {
-      sandbox.stub(element, '_showAuthErrorAlert');
-      element.fire('show-auth-required');
-      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
-          'Log in is required to perform that action.', 'Log in.'));
-    });
+      test('show auth required for 403 with auth error and not authed before',
+          done => {
+            const showAuthErrorStub = sandbox.stub(
+                element, '_showAuthErrorAlert'
+            );
+            const responseText = Promise.resolve('Authentication required\n');
+            sinon.stub(element.$.restAPI, 'getLoggedIn')
+                .returns(Promise.resolve(true));
+            element.fire('server-error',
+                {response: {status: 403, text() { return responseText; }}}
+            );
+            flush(() => {
+              assert.isTrue(showAuthErrorStub.calledOnce);
+              done();
+            });
+          });
 
-    test('show normal Error', done => {
-      const showErrorStub = sandbox.stub(element, '_showErrorDialog');
-      const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
-      element.fire('server-error', {response: {status: 500, text: textSpy}});
-
-      assert.isTrue(textSpy.called);
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        textSpy.lastCall.returnValue,
-      ]).then(() => {
-        assert.isTrue(showErrorStub.calledOnce);
-        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
-            'Error 500: ZOMG'));
-        done();
+      test('recheck auth for 403 with auth error if authed before', done => {
+        // starts with authed state
+        element.$.restAPI.getLoggedIn();
+        const responseText = Promise.resolve('Authentication required\n');
+        sinon.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        element.fire('server-error',
+            {response: {status: 403, text() { return responseText; }}}
+        );
+        flush(() => {
+          assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
+          done();
+        });
       });
-    });
 
-    test('_constructServerErrorMsg', () => {
-      const errorText = 'change conflicts';
-      const status = 409;
-      const statusText = 'Conflict';
-      const url = '/my/test/url';
-
-      assert.equal(element._constructServerErrorMsg({status}),
-          'Error 409');
-      assert.equal(element._constructServerErrorMsg({status, url}),
-          'Error 409: \nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({status, statusText, url}),
-          'Error 409 (Conflict): \nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-      }), 'Error 409 (Conflict): change conflicts' +
-          '\nEndpoint: /my/test/url');
-    });
-
-    test('suppress TOO_MANY_FILES error', done => {
-      const showAlertStub = sandbox.stub(element, '_showAlert');
-      const textSpy = sandbox.spy(() => {
-        return Promise.resolve('too many files to find conflicts');
+      test('show logged in error', () => {
+        sandbox.stub(element, '_showAuthErrorAlert');
+        element.fire('show-auth-required');
+        assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+            'Log in is required to perform that action.', 'Log in.'));
       });
-      element.fire('server-error', {response: {status: 500, text: textSpy}});
 
-      assert.isTrue(textSpy.called);
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        textSpy.lastCall.returnValue,
-      ]).then(() => {
-        assert.isFalse(showAlertStub.called);
-        done();
+      test('show normal Error', done => {
+        const showErrorStub = sandbox.stub(element, '_showErrorDialog');
+        const textSpy = sandbox.spy(() => Promise.resolve('ZOMG'));
+        element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+        assert.isTrue(textSpy.called);
+        flush(() => {
+          assert.isTrue(showErrorStub.calledOnce);
+          assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+              'Error 500: ZOMG'));
+          done();
+        });
       });
-    });
 
-    test('show network error', done => {
-      const consoleErrorStub = sandbox.stub(console, 'error');
-      const showAlertStub = sandbox.stub(element, '_showAlert');
-      element.fire('network-error', {error: new Error('ZOMG')});
-      flush(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
-            'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-        done();
+      test('_constructServerErrorMsg', () => {
+        const errorText = 'change conflicts';
+        const status = 409;
+        const statusText = 'Conflict';
+        const url = '/my/test/url';
+
+        assert.equal(element._constructServerErrorMsg({status}),
+            'Error 409');
+        assert.equal(element._constructServerErrorMsg({status, url}),
+            'Error 409: \nEndpoint: /my/test/url');
+        assert.equal(element.
+            _constructServerErrorMsg({status, statusText, url}),
+        'Error 409 (Conflict): \nEndpoint: /my/test/url');
+        assert.equal(element._constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+        }), 'Error 409 (Conflict): change conflicts' +
+        '\nEndpoint: /my/test/url');
       });
-    });
 
-    test('show auth refresh toast', done => {
-      const refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
-          () => { return Promise.resolve(true); });
-      const toastSpy = sandbox.spy(element, '_createToastAlert');
-      const windowOpen = sandbox.stub(window, 'open');
-      const responseText = Promise.resolve('Authentication required\n');
-      element.fire('server-error',
-          {response: {status: 403, text() { return responseText; }}}
-      );
-      Promise.all([
-        element.$.restAPI.getLoggedIn.lastCall.returnValue,
-        responseText,
-      ]).then(() => {
-        assert.isTrue(toastSpy.called);
+      test('suppress TOO_MANY_FILES error', done => {
+        const showAlertStub = sandbox.stub(element, '_showAlert');
+        const textSpy = sandbox.spy(
+            () => Promise.resolve('too many files to find conflicts')
+        );
+        element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+        assert.isTrue(textSpy.called);
+        flush(() => {
+          assert.isFalse(showAlertStub.called);
+          done();
+        });
+      });
+
+      test('show network error', done => {
+        const consoleErrorStub = sandbox.stub(console, 'error');
+        const showAlertStub = sandbox.stub(element, '_showAlert');
+        element.fire('network-error', {error: new Error('ZOMG')});
+        flush(() => {
+          assert.isTrue(showAlertStub.calledOnce);
+          assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+              'Server unavailable'));
+          assert.isTrue(consoleErrorStub.calledOnce);
+          assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
+          done();
+        });
+      });
+
+      test('show auth refresh toast', done => {
+        // starts with authed state
+        element.$.restAPI.getLoggedIn();
+        const refreshStub = sandbox.stub(element.$.restAPI, 'getAccount',
+            () => Promise.resolve({}));
+        const toastSpy = sandbox.spy(element, '_createToastAlert');
+        const windowOpen = sandbox.stub(window, 'open');
+        const responseText = Promise.resolve('Authentication required\n');
+        // fake failed auth
+        window.fetch.returns(Promise.resolve({status: 403}));
+        element.fire('server-error',
+            {response: {status: 403, text() { return responseText; }}}
+        );
+        assert.equal(window.fetch.callCount, 1);
+        flush(() => {
+          // here needs two flush as there are two chanined
+          // promises on server-error handler and flush only flushes one
+          assert.equal(window.fetch.callCount, 2);
+          flush(() => {
+            // auth-error fired
+            assert.isTrue(toastSpy.called);
+
+            // toast
+            let toast = toastSpy.lastCall.returnValue;
+            assert.isOk(toast);
+            assert.include(
+                Polymer.dom(toast.root).textContent, 'Credentails expired.');
+            assert.include(
+                Polymer.dom(toast.root).textContent, 'Refresh credentials');
+
+            // noInteractionOverlay
+            const noInteractionOverlay = element.$.noInteractionOverlay;
+            assert.isOk(noInteractionOverlay);
+            sinon.spy(noInteractionOverlay, 'close');
+            assert.equal(
+                noInteractionOverlay.backdropElement.getAttribute('opened'),
+                '');
+            assert.isFalse(windowOpen.called);
+            MockInteractions.tap(toast.$$('gr-button.action'));
+            assert.isTrue(windowOpen.called);
+
+            // @see Issue 5822: noopener breaks closeAfterLogin
+            assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
+                -1);
+
+            const hideToastSpy = sandbox.spy(toast, 'hide');
+
+            // now fake authed
+            window.fetch.returns(Promise.resolve({status: 204}));
+            element._handleWindowFocus();
+            element.flushDebouncer('checkLoggedIn');
+            flush(() => {
+              assert.isTrue(refreshStub.called);
+              assert.isTrue(hideToastSpy.called);
+
+              // toast update
+              assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+              toast = toastSpy.lastCall.returnValue;
+              assert.isOk(toast);
+              assert.include(
+                  Polymer.dom(toast.root).textContent, 'Credentials refreshed');
+
+              // close overlay
+              assert.isTrue(noInteractionOverlay.close.called);
+              done();
+            });
+          });
+        });
+      });
+
+      test('auth toast should dismiss existing toast', done => {
+        // starts with authed state
+        element.$.restAPI.getLoggedIn();
+        const toastSpy = sandbox.spy(element, '_createToastAlert');
+        const responseText = Promise.resolve('Authentication required\n');
+
+        // fake an alert
+        element.fire('show-alert', {message: 'test reload', action: 'reload'});
+        const toast = toastSpy.lastCall.returnValue;
+        assert.isOk(toast);
+        assert.include(
+            Polymer.dom(toast.root).textContent, 'test reload');
+
+        // fake auth
+        window.fetch.returns(Promise.resolve({status: 403}));
+        element.fire('server-error',
+            {response: {status: 403, text() { return responseText; }}}
+        );
+        assert.equal(window.fetch.callCount, 1);
+        flush(() => {
+          // here needs two flush as there are two chanined
+          // promises on server-error handler and flush only flushes one
+          assert.equal(window.fetch.callCount, 2);
+          flush(() => {
+            // toast
+            const toast = toastSpy.lastCall.returnValue;
+            assert.include(
+                Polymer.dom(toast.root).textContent, 'Credentails expired.');
+            assert.include(
+                Polymer.dom(toast.root).textContent, 'Refresh credentials');
+            done();
+          });
+        });
+      });
+
+      test('regular toast should dismiss regular toast', () => {
+        // starts with authed state
+        element.$.restAPI.getLoggedIn();
+        const toastSpy = sandbox.spy(element, '_createToastAlert');
+
+        // fake an alert
+        element.fire('show-alert', {message: 'test reload', action: 'reload'});
         let toast = toastSpy.lastCall.returnValue;
         assert.isOk(toast);
         assert.include(
-            Polymer.dom(toast.root).textContent, 'Auth error');
-        assert.include(
-            Polymer.dom(toast.root).textContent, 'Refresh credentials.');
+            Polymer.dom(toast.root).textContent, 'test reload');
 
-        assert.isFalse(windowOpen.called);
-        MockInteractions.tap(toast.$$('gr-button.action'));
-        assert.isTrue(windowOpen.called);
+        // new alert
+        element.fire('show-alert', {message: 'second-test', action: 'reload'});
 
-        // @see Issue 5822: noopener breaks closeAfterLogin
-        assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-            -1);
+        toast = toastSpy.lastCall.returnValue;
+        assert.include(Polymer.dom(toast.root).textContent, 'second-test');
+      });
 
-        const hideToastSpy = sandbox.spy(toast, 'hide');
+      test('regular toast should not dismiss auth toast', done => {
+        // starts with authed state
+        element.$.restAPI.getLoggedIn();
+        const toastSpy = sandbox.spy(element, '_createToastAlert');
+        const responseText = Promise.resolve('Authentication required\n');
 
-        element._handleWindowFocus();
-        assert.isTrue(refreshStub.called);
-        element.flushDebouncer('checkLoggedIn');
+        // fake auth
+        window.fetch.returns(Promise.resolve({status: 403}));
+        element.fire('server-error',
+            {response: {status: 403, text() { return responseText; }}}
+        );
+        assert.equal(window.fetch.callCount, 1);
         flush(() => {
-          assert.isTrue(refreshStub.called);
-          assert.isTrue(hideToastSpy.called);
+          // here needs two flush as there are two chanined
+          // promises on server-error handler and flush only flushes one
+          assert.equal(window.fetch.callCount, 2);
+          flush(() => {
+            let toast = toastSpy.lastCall.returnValue;
+            assert.include(
+                Polymer.dom(toast.root).textContent, 'Credentails expired.');
+            assert.include(
+                Polymer.dom(toast.root).textContent, 'Refresh credentials');
 
-          assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
-          toast = toastSpy.lastCall.returnValue;
-          assert.isOk(toast);
-          assert.include(
-              Polymer.dom(toast.root).textContent, 'Credentials refreshed');
+            // fake an alert
+            element.fire('show-alert', {
+              message: 'test-alert', action: 'reload',
+            });
+            flush(() => {
+              toast = toastSpy.lastCall.returnValue;
+              assert.isOk(toast);
+              assert.include(
+                  Polymer.dom(toast.root).textContent, 'Credentails expired.');
+              done();
+            });
+          });
+        });
+      });
+
+      test('show alert', () => {
+        const alertObj = {message: 'foo'};
+        sandbox.stub(element, '_showAlert');
+        element.fire('show-alert', alertObj);
+        assert.isTrue(element._showAlert.calledOnce);
+        assert.equal(element._showAlert.lastCall.args[0], 'foo');
+        assert.isNotOk(element._showAlert.lastCall.args[1]);
+        assert.isNotOk(element._showAlert.lastCall.args[2]);
+      });
+
+      test('checks stale credentials on visibility change', () => {
+        const refreshStub = sandbox.stub(element,
+            '_checkSignedIn');
+        sandbox.stub(Date, 'now').returns(999999);
+        element._lastCredentialCheck = 0;
+        element._handleVisibilityChange();
+
+        // Since there is no known account, it should not test credentials.
+        assert.isFalse(refreshStub.called);
+        assert.equal(element._lastCredentialCheck, 0);
+
+        element.knownAccountId = 123;
+        element._handleVisibilityChange();
+
+        // Should test credentials, since there is a known account.
+        assert.isTrue(refreshStub.called);
+        assert.equal(element._lastCredentialCheck, 999999);
+      });
+
+      test('refreshes with same credentials', done => {
+        const accountPromise = Promise.resolve({_account_id: 1234});
+        sandbox.stub(element.$.restAPI, 'getAccount')
+            .returns(accountPromise);
+        const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+        const handleRefreshStub = sandbox.stub(element,
+            '_handleCredentialRefreshed');
+        const reloadStub = sandbox.stub(element, '_reloadPage');
+
+        element.knownAccountId = 1234;
+        element._refreshingCredentials = true;
+        element._checkSignedIn();
+
+        flush(() => {
+          assert.isFalse(requestCheckStub.called);
+          assert.isTrue(handleRefreshStub.called);
+          assert.isFalse(reloadStub.called);
+          done();
+        });
+      });
+
+      test('_showAlert hides existing alerts', () => {
+        element._alertElement = element._createToastAlert();
+        const hideStub = sandbox.stub(element, '_hideAlert');
+        element._showAlert();
+        assert.isTrue(hideStub.calledOnce);
+      });
+
+      test('show-error', () => {
+        const openStub = sandbox.stub(element.$.errorOverlay, 'open');
+        const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
+        const reportStub = sandbox.stub(
+            element.$.reporting,
+            'reportErrorDialog'
+        );
+
+        const message = 'test message';
+        element.fire('show-error', {message});
+        flushAsynchronousOperations();
+
+        assert.isTrue(openStub.called);
+        assert.isTrue(reportStub.called);
+        assert.equal(element.$.errorDialog.text, message);
+
+        element.$.errorDialog.fire('dismiss');
+        flushAsynchronousOperations();
+
+        assert.isTrue(closeStub.called);
+      });
+
+      test('reloads when refreshed credentials differ', done => {
+        const accountPromise = Promise.resolve({_account_id: 1234});
+        sandbox.stub(element.$.restAPI, 'getAccount')
+            .returns(accountPromise);
+        const requestCheckStub = sandbox.stub(
+            element,
+            '_requestCheckLoggedIn');
+        const handleRefreshStub = sandbox.stub(element,
+            '_handleCredentialRefreshed');
+        const reloadStub = sandbox.stub(element, '_reloadPage');
+
+        element.knownAccountId = 4321; // Different from 1234
+        element._refreshingCredentials = true;
+        element._checkSignedIn();
+
+        flush(() => {
+          assert.isFalse(requestCheckStub.called);
+          assert.isFalse(handleRefreshStub.called);
+          assert.isTrue(reloadStub.called);
           done();
         });
       });
     });
 
-    test('show alert', () => {
-      const alertObj = {message: 'foo'};
-      sandbox.stub(element, '_showAlert');
-      element.fire('show-alert', alertObj);
-      assert.isTrue(element._showAlert.calledOnce);
-      assert.equal(element._showAlert.lastCall.args[0], 'foo');
-      assert.isNotOk(element._showAlert.lastCall.args[1]);
-      assert.isNotOk(element._showAlert.lastCall.args[2]);
-    });
-
-    test('checks stale credentials on visibility change', () => {
-      const refreshStub = sandbox.stub(element.$.restAPI,
-          'checkCredentials');
-      sandbox.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
-      element._handleVisibilityChange();
-
-      // Since there is no known account, it should not test credentials.
-      assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
-
-      element.knownAccountId = 123;
-      element._handleVisibilityChange();
-
-      // Should test credentials, since there is a known account.
-      assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
-    });
-
-    test('refresh loop continues on credential fail', done => {
-      const accountPromise = Promise.resolve(null);
-      sandbox.stub(element.$.restAPI, 'checkCredentials')
-          .returns(accountPromise);
-      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
-
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      accountPromise.then(() => {
-        assert.isTrue(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
+    suite('when not authed', () => {
+      setup(() => {
+        stub('gr-rest-api-interface', {
+          getLoggedIn() { return Promise.resolve(false); },
+        });
+        element = fixture('basic');
       });
-    });
 
-    test('refreshes with same credentials', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      sandbox.stub(element.$.restAPI, 'checkCredentials')
-          .returns(accountPromise);
-      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
+      test('refresh loop continues on credential fail', done => {
+        const requestCheckStub = sandbox.stub(
+            element,
+            '_requestCheckLoggedIn');
+        const handleRefreshStub = sandbox.stub(element,
+            '_handleCredentialRefreshed');
+        const reloadStub = sandbox.stub(element, '_reloadPage');
 
-      element.knownAccountId = 1234;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+        element._refreshingCredentials = true;
+        element._checkSignedIn();
 
-      accountPromise.then(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isTrue(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
+        flush(() => {
+          assert.isTrue(requestCheckStub.called);
+          assert.isFalse(handleRefreshStub.called);
+          assert.isFalse(reloadStub.called);
+          done();
+        });
       });
     });
-
-    test('reloads when refreshed credentials differ', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      sandbox.stub(element.$.restAPI, 'checkCredentials')
-          .returns(accountPromise);
-      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
-
-      element.knownAccountId = 4321; // Different from 1234
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      accountPromise.then(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isTrue(reloadStub.called);
-        done();
-      });
-    });
-
-    test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
-      const hideStub = sandbox.stub(element, '_hideAlert');
-      element._showAlert();
-      assert.isTrue(hideStub.calledOnce);
-    });
-
-    test('show-error', () => {
-      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
-      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
-      const reportStub = sandbox.stub(element.$.reporting, 'reportErrorDialog');
-
-      const message = 'test message';
-      element.fire('show-error', {message});
-      flushAsynchronousOperations();
-
-      assert.isTrue(openStub.called);
-      assert.isTrue(reportStub.called);
-      assert.equal(element.$.errorDialog.text, message);
-
-      element.$.errorDialog.fire('dismiss');
-      flushAsynchronousOperations();
-
-      assert.isTrue(closeStub.called);
-    });
   });
-</script>
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
index 89d1091..3d424bc 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
@@ -17,20 +17,27 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-key-binding-display',
+  /** @extends Polymer.Element */
+  class GrKeyBindingDisplay extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-key-binding-display'; }
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {Array<string>} */
-      binding: Array,
-    },
+        binding: Array,
+      };
+    }
 
     _computeModifiers(binding) {
       return binding.slice(0, binding.length - 1);
-    },
+    }
 
     _computeKey(binding) {
       return binding[binding.length - 1];
-    },
-  });
+    }
+  }
+
+  customElements.define(GrKeyBindingDisplay.is, GrKeyBindingDisplay);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
index 39c8af8..e0d2d40 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-key-binding-display</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index 4bc6e11..4630ca7 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -19,60 +19,72 @@
 
   const {ShortcutSection} = window.Gerrit.KeyboardShortcutBinder;
 
-  Polymer({
-    is: 'gr-keyboard-shortcuts-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrKeyboardShortcutsDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-keyboard-shortcuts-dialog'; }
     /**
      * Fired when the user presses the close button.
      *
      * @event close
      */
 
-    properties: {
-      _left: Array,
-      _right: Array,
+    static get properties() {
+      return {
+        _left: Array,
+        _right: Array,
 
-      _propertyBySection: {
-        type: Object,
-        value() {
-          return {
-            [ShortcutSection.EVERYWHERE]: '_everywhere',
-            [ShortcutSection.NAVIGATION]: '_navigation',
-            [ShortcutSection.DASHBOARD]: '_dashboard',
-            [ShortcutSection.CHANGE_LIST]: '_changeList',
-            [ShortcutSection.ACTIONS]: '_actions',
-            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
-            [ShortcutSection.FILE_LIST]: '_fileList',
-            [ShortcutSection.DIFFS]: '_diffs',
-          };
+        _propertyBySection: {
+          type: Object,
+          value() {
+            return {
+              [ShortcutSection.EVERYWHERE]: '_everywhere',
+              [ShortcutSection.NAVIGATION]: '_navigation',
+              [ShortcutSection.DASHBOARD]: '_dashboard',
+              [ShortcutSection.CHANGE_LIST]: '_changeList',
+              [ShortcutSection.ACTIONS]: '_actions',
+              [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
+              [ShortcutSection.FILE_LIST]: '_fileList',
+              [ShortcutSection.DIFFS]: '_diffs',
+            };
+          },
         },
-      },
-    },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('role', 'dialog');
+    }
 
-    hostAttributes: {
-      role: 'dialog',
-    },
-
+    /** @override */
     attached() {
+      super.attached();
       this.addKeyboardShortcutDirectoryListener(
           this._onDirectoryUpdated.bind(this));
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.removeKeyboardShortcutDirectoryListener(
           this._onDirectoryUpdated.bind(this));
-    },
+    }
 
     _handleCloseTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('close', null, {bubbles: false});
-    },
+    }
 
     _onDirectoryUpdated(directory) {
       const left = [];
@@ -122,6 +134,9 @@
 
       this.set('_left', left);
       this.set('_right', right);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrKeyboardShortcutsDialog.is,
+      GrKeyboardShortcutsDialog);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
index 1a3d6c7..b713aa1 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-key-binding-display</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index d29858e..e208148 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -141,7 +141,7 @@
       }
       .dropdown-content {
         background-color: var(--view-background-color);
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+        box-shadow: var(--elevation-level-2);
       }
       /*
        * We are not using :host to do this, because :host has a lowest css priority
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 773ad68..53c5ef5 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -69,94 +69,111 @@
     'CUSTOM_EXTENSION',
   ]);
 
-  Polymer({
-    is: 'gr-main-header',
+  /**
+   * @appliesMixin Gerrit.AdminNavMixin
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.DocsUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrMainHeader extends Polymer.mixinBehaviors( [
+    Gerrit.AdminNavBehavior,
+    Gerrit.BaseUrlBehavior,
+    Gerrit.DocsUrlBehavior,
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-main-header'; }
 
-    hostAttributes: {
-      role: 'banner',
-    },
-
-    properties: {
-      searchQuery: {
-        type: String,
-        notify: true,
-      },
-      loggedIn: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-
-      /** @type {?Object} */
-      _account: Object,
-      _adminLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _defaultLinks: {
-        type: Array,
-        value() {
-          return DEFAULT_LINKS;
+    static get properties() {
+      return {
+        searchQuery: {
+          type: String,
+          notify: true,
         },
-      },
-      _docBaseUrl: {
-        type: String,
-        value: null,
-      },
-      _links: {
-        type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
+        loggedIn: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+        loading: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+
+        /** @type {?Object} */
+        _account: Object,
+        _adminLinks: {
+          type: Array,
+          value() { return []; },
+        },
+        _defaultLinks: {
+          type: Array,
+          value() {
+            return DEFAULT_LINKS;
+          },
+        },
+        _docBaseUrl: {
+          type: String,
+          value: null,
+        },
+        _links: {
+          type: Array,
+          computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
             '_topMenus, _docBaseUrl)',
-      },
-      _loginURL: {
-        type: String,
-        value: '/login',
-      },
-      _userLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _topMenus: {
-        type: Array,
-        value() { return []; },
-      },
-      _registerText: {
-        type: String,
-        value: 'Sign up',
-      },
-      _registerURL: {
-        type: String,
-        value: null,
-      },
-    },
+        },
+        _loginURL: {
+          type: String,
+          value: '/login',
+        },
+        _userLinks: {
+          type: Array,
+          value() { return []; },
+        },
+        _topMenus: {
+          type: Array,
+          value() { return []; },
+        },
+        _registerText: {
+          type: String,
+          value: 'Sign up',
+        },
+        _registerURL: {
+          type: String,
+          value: null,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.AdminNavBehavior,
-      Gerrit.BaseUrlBehavior,
-      Gerrit.DocsUrlBehavior,
-      Gerrit.FireBehavior,
-    ],
+    static get observers() {
+      return [
+        '_accountLoaded(_account)',
+      ];
+    }
 
-    observers: [
-      '_accountLoaded(_account)',
-    ],
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('role', 'banner');
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._loadAccount();
       this._loadConfig();
       this.listen(window, 'location-change', '_handleLocationChange');
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'location-change', '_handleLocationChange');
-    },
+    }
 
     reload() {
       this._loadAccount();
-    },
+    }
 
     _handleLocationChange(e) {
       const baseUrl = this.getBaseUrl();
@@ -173,11 +190,11 @@
             window.location.search +
             window.location.hash);
       }
-    },
+    }
 
     _computeRelativeURL(path) {
       return '//' + window.location.host + this.getBaseUrl() + path;
-    },
+    }
 
     _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
       // Polymer 2: check for undefined
@@ -218,10 +235,10 @@
       const topMenuLinks = [];
       links.forEach(link => { topMenuLinks[link.title] = link.links; });
       for (const m of topMenus) {
-        const items = m.items.map(this._fixCustomMenuItem).filter(link => {
+        const items = m.items.map(this._fixCustomMenuItem).filter(link =>
           // Ignore GWT project links
-          return !link.url.includes('${projectName}');
-        });
+          !link.url.includes('${projectName}')
+        );
         if (m.name in topMenuLinks) {
           items.forEach(link => { topMenuLinks[m.name].push(link); });
         } else {
@@ -232,7 +249,7 @@
         }
       }
       return links;
-    },
+    }
 
     _getDocLinks(docBaseUrl, docLinks) {
       if (!docBaseUrl || !docLinks) {
@@ -249,7 +266,7 @@
           target: '_blank',
         };
       });
-    },
+    }
 
     _loadAccount() {
       this.loading = true;
@@ -273,7 +290,7 @@
               this._adminLinks = res.links;
             });
       });
-    },
+    }
 
     _loadConfig() {
       this.$.restAPI.getConfig()
@@ -282,15 +299,16 @@
             return this.getDocsBaseUrl(config, this.$.restAPI);
           })
           .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
-    },
+    }
 
     _accountLoaded(account) {
       if (!account) { return; }
 
       this.$.restAPI.getPreferences().then(prefs => {
-        this._userLinks = prefs.my.map(this._fixCustomMenuItem);
+        this._userLinks = prefs && prefs.my ?
+          prefs.my.map(this._fixCustomMenuItem) : [];
       });
-    },
+    }
 
     _retrieveRegisterURL(config) {
       if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
@@ -299,11 +317,11 @@
           this._registerText = config.auth.register_text;
         }
       }
-    },
+    }
 
     _computeIsInvisible(registerURL) {
       return registerURL ? '' : 'invisible';
-    },
+    }
 
     _fixCustomMenuItem(linkObj) {
       // Normalize all urls to PolyGerrit style.
@@ -321,17 +339,17 @@
       delete linkObj.target;
 
       return linkObj;
-    },
+    }
 
     _generateSettingsLink() {
       return this.getBaseUrl() + '/settings/';
-    },
+    }
 
     _onMobileSearchTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('mobile-search', null, {bubbles: false});
-    },
+    }
 
     _computeLinkGroupClass(linkGroup) {
       if (linkGroup && linkGroup.class) {
@@ -339,6 +357,8 @@
       }
 
       return '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrMainHeader.is, GrMainHeader);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 3309aa5..403d9d5 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-main-header</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
index 73ce86a..ca44430 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-navigation</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 276c137..889f1f3 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -24,7 +24,6 @@
     CATEGORY_RPC: 'RPC Timing',
     // Reported events - alphabetize below.
     APP_STARTED: 'App Started',
-    PAGE_LOADED: 'Page Loaded',
   };
 
   // Plugin-related reporting constants.
@@ -41,14 +40,6 @@
     DETECTED: 'Extension detected',
   };
 
-  // Page visibility related constants.
-  const PAGE_VISIBILITY = {
-    TYPE: 'lifecycle',
-    CATEGORY: 'Page Visibility',
-    // Reported events - alphabetize below.
-    STARTED_HIDDEN: 'hidden',
-  };
-
   // Navigation reporting constants.
   const NAVIGATION = {
     TYPE: 'nav-report',
@@ -107,8 +98,10 @@
 
   const pending = [];
 
+  // Variables that hold context info in global scope
   const loadedPlugins = [];
   const detectedExtensions = [];
+  let reportRepoName = undefined;
 
   const onError = function(oldOnError, msg, url, line, column, error) {
     if (oldOnError) {
@@ -148,6 +141,25 @@
   };
   catchErrors();
 
+  // PerformanceObserver interface is a browser API.
+  if (window.PerformanceObserver) {
+    const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
+    // Safari doesn't support longtask yet
+    if (supportedEntryTypes.includes('longtask')) {
+      const catchLongJsTasks = new PerformanceObserver(list => {
+        for (const task of list.getEntries()) {
+          // We are interested in longtask longer than 200 ms (default is 50 ms)
+          if (task.duration > 200) {
+            GrReporting.prototype.reporter(TIMING.TYPE,
+                TIMING.CATEGORY_UI_LATENCY, `Task ${task.name}`,
+                Math.round(task.duration), false);
+          }
+        }
+      });
+      catchLongJsTasks.observe({entryTypes: ['longtask']});
+    }
+  }
+
   // The Polymer pass of JSCompiler requires this to be reassignable
   // eslint-disable-next-line prefer-const
   let GrReporting = Polymer({
@@ -172,7 +184,7 @@
     },
 
     now() {
-      return window.performance.now();
+      return Math.round(window.performance.now());
     },
 
     _arePluginsLoaded() {
@@ -188,7 +200,14 @@
     reporter(...args) {
       const report = (this._isMetricsPluginLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
-      args.splice(4, 0, loadedPlugins, detectedExtensions);
+      const contextInfo = {
+        loadedPlugins,
+        detectedExtensions,
+        repoName: reportRepoName,
+        isInBackgroundTab: document.visibilityState === 'hidden',
+        startTimeMs: this.now(),
+      };
+      args.splice(4, 0, contextInfo);
       report.apply(this, args);
     },
 
@@ -199,22 +218,30 @@
      * @param {string} category
      * @param {string} eventName
      * @param {string|number} eventValue
-     * @param {Array} plugins
-     * @param {Array} extensions
+     * @param {Object} contextInfo
      * @param {boolean|undefined} opt_noLog If true, the event will not be
      *     logged to the JS console.
      */
-    defaultReporter(type, category, eventName, eventValue,
-        loadedPlugins, detectedExtensions, opt_noLog) {
+    defaultReporter(type, category, eventName, eventValue, contextInfo,
+        opt_noLog) {
       const detail = {
         type,
         category,
         name: eventName,
         value: eventValue,
       };
-      if (category === TIMING.CATEGORY_UI_LATENCY) {
-        detail.loadedPlugins = loadedPlugins;
-        detail.detectedExtensions = detectedExtensions;
+      if (category === TIMING.CATEGORY_UI_LATENCY && contextInfo) {
+        detail.loadedPlugins = contextInfo.loadedPlugins;
+        detail.detectedExtensions = contextInfo.detectedExtensions;
+      }
+      if (contextInfo && contextInfo.repoName) {
+        detail.repoName = contextInfo.repoName;
+      }
+      if (contextInfo && contextInfo.isInBackgroundTab !== undefined) {
+        detail.inBackgroundTab = contextInfo.isInBackgroundTab;
+      }
+      if (contextInfo && contextInfo.startTimeMs) {
+        detail.eventStart = contextInfo.startTimeMs;
       }
       document.dispatchEvent(new CustomEvent(type, {detail}));
       if (opt_noLog) { return; }
@@ -237,53 +264,60 @@
      * @param {string} category
      * @param {string} eventName
      * @param {string|number} eventValue
-     * @param {Array} plugins
-     * @param {Array} extensions
+     * @param {Object} contextInfo
      * @param {boolean|undefined} opt_noLog If true, the event will not be
      *     logged to the JS console.
      */
-    cachingReporter(type, category, eventName, eventValue,
-        plugins, extensions, opt_noLog) {
+    cachingReporter(type, category, eventName, eventValue, contextInfo,
+        opt_noLog) {
       if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
         console.error(eventValue && eventValue.error || eventName);
       }
       if (this._isMetricsPluginLoaded()) {
         if (pending.length) {
           for (const args of pending.splice(0)) {
-            this.reporter(...args);
+            this.defaultReporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue,
-            plugins, extensions, opt_noLog);
+        this.defaultReporter(type, category, eventName, eventValue, contextInfo,
+            opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue,
-          plugins, extensions, opt_noLog]);
+        pending.push([type, category, eventName, eventValue, contextInfo,
+          opt_noLog]);
       }
     },
 
     /**
      * User-perceived app start time, should be reported when the app is ready.
      */
-    appStarted(hidden) {
+    appStarted() {
       this.timeEnd(TIMING.APP_STARTED);
-      if (hidden) {
-        this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
-            PAGE_VISIBILITY.STARTED_HIDDEN);
-      }
     },
 
     /**
-     * Page load time, should be reported at any time after navigation.
+     * Page load time and other metrics, should be reported at any time
+     * after navigation.
      */
     pageLoaded() {
       if (this.performanceTiming.loadEventEnd === 0) {
         console.error('pageLoaded should be called after window.onload');
         this.async(this.pageLoaded, 100);
       } else {
-        const loadTime = this.performanceTiming.loadEventEnd -
+        const perfEvents = Object.keys(this.performanceTiming.toJSON());
+        perfEvents.forEach(
+            eventName => this._reportPerformanceTiming(eventName)
+        );
+      }
+    },
+
+    _reportPerformanceTiming(eventName) {
+      const eventTiming = this.performanceTiming[eventName];
+      if (eventTiming > 0) {
+        const elapsedTime = eventTiming -
             this.performanceTiming.navigationStart;
+        // NavResTime - Navigation and resource timings.
         this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
-            TIMING.PAGE_LOADED, loadTime);
+            `NavResTime - ${eventName}`, elapsedTime, true);
       }
     },
 
@@ -298,6 +332,7 @@
       this.time(TIMER.DIFF_VIEW_DISPLAYED);
       this.time(TIMER.DIFF_VIEW_LOAD_FULL);
       this.time(TIMER.FILE_LIST_DISPLAYED);
+      reportRepoName = undefined;
     },
 
     locationChanged(page) {
@@ -426,7 +461,7 @@
 
       // Guard against division by zero.
       if (!denominator) { return; }
-      const time = Math.round(this.now() - baseTime);
+      const time = this.now() - baseTime;
       this._reportTiming(averageName, time / denominator);
     },
 
@@ -437,8 +472,7 @@
      * @param {number} time The time to report as an integer of milliseconds.
      */
     _reportTiming(name, time) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name,
-          Math.round(time));
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name, time);
     },
 
     /**
@@ -529,6 +563,10 @@
       this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
           'ErrorDialog: ' + message, {error: new Error(message)});
     },
+
+    setRepoName(repoName) {
+      reportRepoName = repoName;
+    },
   });
 
   window.GrReporting = GrReporting;
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index 4c561a2..c7aa896 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reporting</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -52,6 +52,7 @@
         navigationStart: 1,
         loadEventEnd: 2,
       };
+      fakePerformance.toJSON = () => fakePerformance;
       sinon.stub(element, 'performanceTiming',
           {get() { return fakePerformance; }});
       sandbox.stub(element, 'reporter');
@@ -64,15 +65,11 @@
 
     test('appStarted', () => {
       sandbox.stub(element, 'now').returns(42);
-      element.appStarted(true);
+      element.appStarted();
       assert.isTrue(
           element.reporter.calledWithExactly(
               'timing-report', 'UI Latency', 'App Started', 42
           ));
-      assert.isTrue(
-          element.reporter.calledWithExactly(
-              'lifecycle', 'Page Visibility', 'hidden'
-          ));
     });
 
     test('WebComponentsReady', () => {
@@ -87,8 +84,9 @@
       element.pageLoaded();
       assert.isTrue(
           element.reporter.calledWithExactly(
-              'timing-report', 'UI Latency', 'Page Loaded',
-              fakePerformance.loadEventEnd - fakePerformance.navigationStart)
+              'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
+              fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+              true)
       );
     });
 
@@ -162,14 +160,14 @@
     test('time and timeEnd', () => {
       const nowStub = sandbox.stub(element, 'now').returns(0);
       element.time('foo');
-      nowStub.returns(1.1);
+      nowStub.returns(1);
       element.time('bar');
       nowStub.returns(2);
       element.timeEnd('bar');
-      nowStub.returns(3.511);
+      nowStub.returns(3);
       element.timeEnd('foo');
       assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'foo', 4
+          'timing-report', 'UI Latency', 'foo', 3
       ));
       assert.isTrue(element.reporter.calledWithExactly(
           'timing-report', 'UI Latency', 'bar', 1
@@ -251,6 +249,21 @@
       ));
     });
 
+    test('report start time', () => {
+      element.reporter.restore();
+      sandbox.stub(element, 'now').returns(42);
+      sandbox.spy(element, 'defaultReporter');
+      const dispatchStub = sandbox.spy(document, 'dispatchEvent');
+      element.pluginsLoaded();
+      element.time('timeAction');
+      element.timeEnd('timeAction');
+      assert.isTrue(element.defaultReporter.getCall(2).calledWithMatch(
+          'timing-report', 'UI Latency', 'timeAction', 0,
+          {startTimeMs: 42}
+      ));
+      assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
+    });
+
     suite('plugins', () => {
       setup(() => {
         element.reporter.restore();
@@ -289,9 +302,9 @@
         // element.pluginLoaded('foo');
         element.time('timeAction');
         element.timeEnd('timeAction');
-        assert.isTrue(element.defaultReporter.getCall(1).calledWith(
+        assert.isTrue(element.defaultReporter.getCall(1).calledWithMatch(
             'timing-report', 'UI Latency', 'timeAction', 0,
-            ['metrics-xyz1']
+            {loadedPlugins: ['metrics-xyz1']}
         ));
       });
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 8218b07..2bd403b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -209,38 +209,47 @@
     });
   })();
 
-  Polymer({
-    is: 'gr-router',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrRouter extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-router'; }
 
-    properties: {
-      _app: {
-        type: Object,
-        value: app,
-      },
-      _isRedirecting: Boolean,
-      // This variable is to differentiate between internal navigation (false)
-      // and for first navigation in app after loaded from server (true).
-      _isInitialLoad: {
-        type: Boolean,
-        value: true,
-      },
-    },
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
+    static get properties() {
+      return {
+        _app: {
+          type: Object,
+          value: app,
+        },
+        _isRedirecting: Boolean,
+        // This variable is to differentiate between internal navigation (false)
+        // and for first navigation in app after loaded from server (true).
+        _isInitialLoad: {
+          type: Boolean,
+          value: true,
+        },
+      };
+    }
 
     start() {
       if (!this._app) { return; }
       this._startRouter();
-    },
+    }
 
     _setParams(params) {
       this._appElement().params = params;
-    },
+    }
 
     _appElement() {
       // In Polymer2 you have to reach through the shadow root of the app
@@ -250,12 +259,12 @@
       return document.getElementById('app-element') ||
           document.getElementById('app').shadowRoot.getElementById(
               'app-element');
-    },
+    }
 
     _redirect(url) {
       this._isRedirecting = true;
       page.redirect(url);
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -287,7 +296,7 @@
       }
 
       return base + url;
-    },
+    }
 
     _generateWeblinks(params) {
       const type = params.type;
@@ -301,7 +310,7 @@
         default:
           console.warn(`Unsupported weblink ${type}!`);
       }
-    },
+    }
 
     _getPatchSetWeblink(params) {
       const {commit, options} = params;
@@ -313,7 +322,7 @@
       } else {
         return {name, url: weblink.url};
       }
-    },
+    }
 
     _firstCodeBrowserWeblink(weblinks) {
       // This is an ordered whitelist of web link types that provide direct
@@ -325,8 +334,7 @@
         if (weblink) { return weblink; }
       }
       return null;
-    },
-
+    }
 
     _getBrowseCommitWeblink(weblinks, config) {
       if (!weblinks) { return null; }
@@ -342,7 +350,7 @@
       }
       if (!weblink) { return null; }
       return weblink;
-    },
+    }
 
     _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
       if (!weblinks || !weblinks.length) return [];
@@ -351,11 +359,11 @@
         !commitWeblink ||
         !commitWeblink.name ||
         weblink.name !== commitWeblink.name);
-    },
+    }
 
     _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
       return weblinks;
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -402,7 +410,7 @@
       }
 
       return '/q/' + operators.join('+') + offsetExpr;
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -426,7 +434,7 @@
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -451,7 +459,7 @@
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
       }
-    },
+    }
 
     /**
      * @param {!Array<!{name: string, query: string}>} sections
@@ -468,7 +476,7 @@
         return encodeURIComponent(section.name) + '=' +
             encodeURIComponent(query);
       });
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -494,7 +502,7 @@
       } else {
         return `/c/${params.changeNum}${suffix}`;
       }
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -508,7 +516,7 @@
         url += ',audit-log';
       }
       return url;
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -528,7 +536,7 @@
         url += ',dashboards';
       }
       return url;
-    },
+    }
 
     /**
      * @param {!Object} params
@@ -536,7 +544,7 @@
      */
     _generateSettingsUrl(params) {
       return '/settings';
-    },
+    }
 
     /**
      * Given an object of parameters, potentially including a `patchNum` or a
@@ -551,7 +559,7 @@
       if (params.patchNum) { range = '' + params.patchNum; }
       if (params.basePatchNum) { range = params.basePatchNum + '..' + range; }
       return range;
-    },
+    }
 
     /**
      * Given a set of params without a project, gets the project from the rest
@@ -575,7 +583,7 @@
             this._normalizePatchRangeParams(params);
             this._redirect(this._generateUrl(params));
           });
-    },
+    }
 
     /**
      * Normalizes the params object, and determines if the URL needs to be
@@ -604,7 +612,7 @@
         params.basePatchNum = null;
       }
       return needsRedirect;
-    },
+    }
 
     /**
      * Redirect the user to login using the given return-URL for redirection
@@ -616,7 +624,7 @@
       const basePath = this.getBaseUrl() || '';
       page(
           '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
-    },
+    }
 
     /**
      * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
@@ -627,8 +635,9 @@
      * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
      */
     _getHashFromCanonicalPath(canonicalPath) {
-      return canonicalPath.split('#').slice(1).join('#');
-    },
+      return canonicalPath.split('#').slice(1)
+          .join('#');
+    }
 
     _parseLineAddress(hash) {
       const match = hash.match(LINE_ADDRESS_PATTERN);
@@ -637,7 +646,7 @@
         leftSide: !!match[1],
         lineNum: parseInt(match[2], 10),
       };
-    },
+    }
 
     /**
      * Check to see if the user is logged in and return a promise that only
@@ -657,12 +666,12 @@
           return Promise.reject(new Error());
         }
       });
-    },
+    }
 
     /**  Page.js middleware that warms the REST API's logged-in cache line. */
     _loadUserMiddleware(ctx, next) {
       this.$.restAPI.getLoggedIn().then(() => { next(); });
-    },
+    }
 
     /**
      * Map a route to a method on the router.
@@ -689,7 +698,7 @@
           this._redirectIfNotLoggedIn(data) : Promise.resolve();
         promise.then(() => { this[handlerName](data); });
       });
-    },
+    }
 
     _startRouter() {
       const base = this.getBaseUrl();
@@ -885,7 +894,7 @@
       this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
 
       page.start();
-    },
+    }
 
     /**
      * @param {!Object} data
@@ -926,7 +935,7 @@
           this._redirect('/q/status:open');
         }
       });
-    },
+    }
 
     /**
      * Decode an application/x-www-form-urlencoded string.
@@ -936,7 +945,7 @@
      */
     _decodeQueryString(qs) {
       return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
-    },
+    }
 
     /**
      * Parse a query string (e.g. window.location.search) into an array of
@@ -968,7 +977,7 @@
         }
       });
       return params;
-    },
+    }
 
     /**
      * Handle dashboard routes. These may be user, or project dashboards.
@@ -993,7 +1002,7 @@
           });
         }
       });
-    },
+    }
 
     /**
      * Handle custom dashboard routes.
@@ -1021,8 +1030,8 @@
         forEachQuery = forEachParam[1];
       }
       const sectionParams = queryParams.filter(
-          elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title'
-          && elem[0].toLowerCase() !== 'foreach');
+          elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title' &&
+          elem[0].toLowerCase() !== 'foreach');
       const sections = sectionParams.map(elem => {
         const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
         return {
@@ -1045,30 +1054,32 @@
       // Redirect /dashboard/ -> /dashboard/self.
       this._redirect('/dashboard/self');
       return Promise.resolve();
-    },
+    }
 
     _handleProjectDashboardRoute(data) {
+      const project = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.DASHBOARD,
-        project: data.params[0],
+        project,
         dashboard: decodeURIComponent(data.params[1]),
       });
-    },
+      this.$.reporting.setRepoName(project);
+    }
 
     _handleGroupInfoRoute(data) {
       this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
-    },
+    }
 
     _handleGroupSelfRedirectRoute(data) {
       this._redirect('/settings/#Groups');
-    },
+    }
 
     _handleGroupRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.GROUP,
         groupId: data.params[0],
       });
-    },
+    }
 
     _handleGroupAuditLogRoute(data) {
       this._setParams({
@@ -1076,7 +1087,7 @@
         detail: Gerrit.Nav.GroupDetailView.LOG,
         groupId: data.params[0],
       });
-    },
+    }
 
     _handleGroupMembersRoute(data) {
       this._setParams({
@@ -1084,7 +1095,7 @@
         detail: Gerrit.Nav.GroupDetailView.MEMBERS,
         groupId: data.params[0],
       });
-    },
+    }
 
     _handleGroupListOffsetRoute(data) {
       this._setParams({
@@ -1094,7 +1105,7 @@
         filter: null,
         openCreateModal: data.hash === 'create',
       });
-    },
+    }
 
     _handleGroupListFilterOffsetRoute(data) {
       this._setParams({
@@ -1103,7 +1114,7 @@
         offset: data.params.offset,
         filter: data.params.filter,
       });
-    },
+    }
 
     _handleGroupListFilterRoute(data) {
       this._setParams({
@@ -1111,7 +1122,7 @@
         adminView: 'gr-admin-group-list',
         filter: data.params.filter || null,
       });
-    },
+    }
 
     _handleProjectsOldRoute(data) {
       let params = '';
@@ -1124,31 +1135,37 @@
       }
 
       this._redirect(`/admin/repos/${params}`);
-    },
+    }
 
     _handleRepoCommandsRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.COMMANDS,
-        repo: data.params[0],
+        repo,
       });
-    },
+      this.$.reporting.setRepoName(repo);
+    }
 
     _handleRepoAccessRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.ACCESS,
-        repo: data.params[0],
+        repo,
       });
-    },
+      this.$.reporting.setRepoName(repo);
+    }
 
     _handleRepoDashboardsRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
         detail: Gerrit.Nav.RepoDetailView.DASHBOARDS,
-        repo: data.params[0],
+        repo,
       });
-    },
+      this.$.reporting.setRepoName(repo);
+    }
 
     _handleBranchListOffsetRoute(data) {
       this._setParams({
@@ -1158,7 +1175,7 @@
         offset: data.params[2] || 0,
         filter: null,
       });
-    },
+    }
 
     _handleBranchListFilterOffsetRoute(data) {
       this._setParams({
@@ -1168,7 +1185,7 @@
         offset: data.params.offset,
         filter: data.params.filter,
       });
-    },
+    }
 
     _handleBranchListFilterRoute(data) {
       this._setParams({
@@ -1177,7 +1194,7 @@
         repo: data.params.repo,
         filter: data.params.filter || null,
       });
-    },
+    }
 
     _handleTagListOffsetRoute(data) {
       this._setParams({
@@ -1187,7 +1204,7 @@
         offset: data.params[2] || 0,
         filter: null,
       });
-    },
+    }
 
     _handleTagListFilterOffsetRoute(data) {
       this._setParams({
@@ -1197,7 +1214,7 @@
         offset: data.params.offset,
         filter: data.params.filter,
       });
-    },
+    }
 
     _handleTagListFilterRoute(data) {
       this._setParams({
@@ -1206,7 +1223,7 @@
         repo: data.params.repo,
         filter: data.params.filter || null,
       });
-    },
+    }
 
     _handleRepoListOffsetRoute(data) {
       this._setParams({
@@ -1216,7 +1233,7 @@
         filter: null,
         openCreateModal: data.hash === 'create',
       });
-    },
+    }
 
     _handleRepoListFilterOffsetRoute(data) {
       this._setParams({
@@ -1225,7 +1242,7 @@
         offset: data.params.offset,
         filter: data.params.filter,
       });
-    },
+    }
 
     _handleRepoListFilterRoute(data) {
       this._setParams({
@@ -1233,26 +1250,28 @@
         adminView: 'gr-repo-list',
         filter: data.params.filter || null,
       });
-    },
+    }
 
     _handleCreateProjectRoute(data) {
       // Redirects the legacy route to the new route, which displays the project
       // list with a hash 'create'.
       this._redirect('/admin/repos#create');
-    },
+    }
 
     _handleCreateGroupRoute(data) {
       // Redirects the legacy route to the new route, which displays the group
       // list with a hash 'create'.
       this._redirect('/admin/groups#create');
-    },
+    }
 
     _handleRepoRoute(data) {
+      const repo = data.params[0];
       this._setParams({
         view: Gerrit.Nav.View.REPO,
-        repo: data.params[0],
+        repo,
       });
-    },
+      this.$.reporting.setRepoName(repo);
+    }
 
     _handlePluginListOffsetRoute(data) {
       this._setParams({
@@ -1261,7 +1280,7 @@
         offset: data.params[1] || 0,
         filter: null,
       });
-    },
+    }
 
     _handlePluginListFilterOffsetRoute(data) {
       this._setParams({
@@ -1270,7 +1289,7 @@
         offset: data.params.offset,
         filter: data.params.filter,
       });
-    },
+    }
 
     _handlePluginListFilterRoute(data) {
       this._setParams({
@@ -1278,14 +1297,14 @@
         adminView: 'gr-plugin-list',
         filter: data.params.filter || null,
       });
-    },
+    }
 
     _handlePluginListRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
         adminView: 'gr-plugin-list',
       });
-    },
+    }
 
     _handleQueryRoute(data) {
       this._setParams({
@@ -1293,15 +1312,15 @@
         query: data.params[0],
         offset: data.params[2],
       });
-    },
+    }
 
     _handleQueryLegacySuffixRoute(ctx) {
       this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
-    },
+    }
 
     _handleChangeNumberLegacyRoute(ctx) {
       this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
-    },
+    }
 
     _handleChangeRoute(ctx) {
       // Parameter order is based on the regex group number matched.
@@ -1313,8 +1332,9 @@
         view: Gerrit.Nav.View.CHANGE,
       };
 
+      this.$.reporting.setRepoName(params.project);
       this._redirectOrNavigate(params);
-    },
+    }
 
     _handleDiffRoute(ctx) {
       // Parameter order is based on the regex group number matched.
@@ -1332,9 +1352,9 @@
         params.leftSide = address.leftSide;
         params.lineNum = address.lineNum;
       }
-
+      this.$.reporting.setRepoName(params.project);
       this._redirectOrNavigate(params);
-    },
+    }
 
     _handleChangeLegacyRoute(ctx) {
       // Parameter order is based on the regex group number matched.
@@ -1347,11 +1367,11 @@
       };
 
       this._normalizeLegacyRouteParams(params);
-    },
+    }
 
     _handleLegacyLinenum(ctx) {
       this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
-    },
+    }
 
     _handleDiffLegacyRoute(ctx) {
       // Parameter order is based on the regex group number matched.
@@ -1370,29 +1390,33 @@
       }
 
       this._normalizeLegacyRouteParams(params);
-    },
+    }
 
     _handleDiffEditRoute(ctx) {
       // Parameter order is based on the regex group number matched.
+      const project = ctx.params[0];
       this._redirectOrNavigate({
-        project: ctx.params[0],
+        project,
         changeNum: ctx.params[1],
         patchNum: ctx.params[2],
         path: ctx.params[3],
         view: Gerrit.Nav.View.EDIT,
       });
-    },
+      this.$.reporting.setRepoName(project);
+    }
 
     _handleChangeEditRoute(ctx) {
       // Parameter order is based on the regex group number matched.
+      const project = ctx.params[0];
       this._redirectOrNavigate({
-        project: ctx.params[0],
+        project,
         changeNum: ctx.params[1],
         patchNum: ctx.params[3],
         view: Gerrit.Nav.View.CHANGE,
         edit: true,
       });
-    },
+      this.$.reporting.setRepoName(project);
+    }
 
     /**
      * Normalize the patch range params for a the change or diff view and
@@ -1405,16 +1429,16 @@
       } else {
         this._setParams(params);
       }
-    },
+    }
 
     _handleAgreementsRoute() {
       this._redirect('/settings/#Agreements');
-    },
+    }
 
     _handleNewAgreementsRoute(data) {
       data.params.view = Gerrit.Nav.View.AGREEMENTS;
       this._setParams(data.params);
-    },
+    }
 
     _handleSettingsLegacyRoute(data) {
       // email tokens may contain '+' but no space.
@@ -1425,11 +1449,11 @@
         view: Gerrit.Nav.View.SETTINGS,
         emailToken: token,
       });
-    },
+    }
 
     _handleSettingsRoute(data) {
       this._setParams({view: Gerrit.Nav.View.SETTINGS});
-    },
+    }
 
     _handleRegisterRoute(ctx) {
       this._setParams({justRegistered: true});
@@ -1440,7 +1464,7 @@
 
       if (path[0] !== '/') { return; }
       this._redirect(this.getBaseUrl() + path);
-    },
+    }
 
     /**
      * Handler for routes that should pass through the router and not be caught
@@ -1448,8 +1472,7 @@
      */
     _handlePassThroughRoute() {
       location.reload();
-    },
-
+    }
 
     /**
      * URL may sometimes have /+/ encoded to / /.
@@ -1459,26 +1482,26 @@
       let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
       if (hash.length) { hash = '#' + hash; }
       this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
-    },
+    }
 
     _handlePluginScreen(ctx) {
       const view = Gerrit.Nav.View.PLUGIN_SCREEN;
       const plugin = ctx.params[0];
       const screen = ctx.params[1];
       this._setParams({view, plugin, screen});
-    },
+    }
 
     _handleDocumentationSearchRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.DOCUMENTATION_SEARCH,
         filter: data.params.filter || null,
       });
-    },
+    }
 
     _handleDocumentationSearchRedirectRoute(data) {
       this._redirect('/Documentation/q/filter:' +
           encodeURIComponent(data.params[0]));
-    },
+    }
 
     _handleDocumentationRedirectRoute(data) {
       if (data.params[1]) {
@@ -1487,7 +1510,7 @@
         // Redirect /Documentation to /Documentation/index.html
         this._redirect('/Documentation/index.html');
       }
-    },
+    }
 
     /**
      * Catchall route for when no other route is matched.
@@ -1500,7 +1523,7 @@
         // Route can be recognized by server, so we pass it to server.
         this._handlePassThroughRoute();
       }
-    },
+    }
 
     _show404() {
       // Note: the app's 404 display is tightly-coupled with catching 404
@@ -1508,6 +1531,8 @@
       // TODO: Decouple the gr-app error view from network responses.
       this._appElement().dispatchEvent(new CustomEvent('page-error',
           {detail: {response: {status: 404}}}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRouter.is, GrRouter);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index 4bfc35b..656f13a 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-router</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -74,7 +74,7 @@
     test('_getChangeWeblinks', () => {
       const link = {name: 'test', url: 'test/url'};
       const browserLink = {name: 'browser', url: 'browser/url'};
-      const mapLinksToConfig = weblinks => ({options: {weblinks}});
+      const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
       sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
 
       assert.deepEqual(
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 0cdef8c..1b57ddb 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -29,11 +29,9 @@
       }
       gr-autocomplete {
         background-color: var(--view-background-color);
-        border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
         flex: 1;
         outline: none;
-        padding: var(--spacing-xs);
       }
     </style>
     <form>
@@ -45,7 +43,6 @@
           on-commit="_handleInputCommit"
           allow-non-suggested-values
           multi
-          borderless
           threshold="[[_threshold]]"
           tab-complete
           vertical-offset="30"></gr-autocomplete>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 0030bab..335813a 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -29,6 +29,7 @@
     'cc:',
     'cc:self',
     'change:',
+    'cherrypickof:',
     'comment:',
     'commentby:',
     'commit:',
@@ -90,6 +91,7 @@
     'status:merged',
     'status:open',
     'status:reviewed',
+    'submissionid:',
     'topic:',
     'tr:',
   ];
@@ -102,75 +104,81 @@
 
   const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
-  Polymer({
-    is: 'gr-search-bar',
-
+  /**
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrSearchBar extends Polymer.mixinBehaviors( [
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-search-bar'; }
     /**
      * Fired when a search is committed
      *
      * @event handle-search
      */
 
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
-    properties: {
-      value: {
-        type: String,
-        value: '',
-        notify: true,
-        observer: '_valueChanged',
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      query: {
-        type: Function,
-        value() {
-          return this._getSearchSuggestions.bind(this);
+    static get properties() {
+      return {
+        value: {
+          type: String,
+          value: '',
+          notify: true,
+          observer: '_valueChanged',
         },
-      },
-      projectSuggestions: {
-        type: Function,
-        value() {
-          return () => Promise.resolve([]);
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
         },
-      },
-      groupSuggestions: {
-        type: Function,
-        value() {
-          return () => Promise.resolve([]);
+        query: {
+          type: Function,
+          value() {
+            return this._getSearchSuggestions.bind(this);
+          },
         },
-      },
-      accountSuggestions: {
-        type: Function,
-        value() {
-          return () => Promise.resolve([]);
+        projectSuggestions: {
+          type: Function,
+          value() {
+            return () => Promise.resolve([]);
+          },
         },
-      },
-      _inputVal: String,
-      _threshold: {
-        type: Number,
-        value: 1,
-      },
-    },
+        groupSuggestions: {
+          type: Function,
+          value() {
+            return () => Promise.resolve([]);
+          },
+        },
+        accountSuggestions: {
+          type: Function,
+          value() {
+            return () => Promise.resolve([]);
+          },
+        },
+        _inputVal: String,
+        _threshold: {
+          type: Number,
+          value: 1,
+        },
+      };
+    }
 
     keyboardShortcuts() {
       return {
         [this.Shortcut.SEARCH]: '_handleSearch',
       };
-    },
+    }
 
     _valueChanged(value) {
       this._inputVal = value;
-    },
+    }
 
     _handleInputCommit(e) {
       this._preventDefaultAndNavigateToInputVal(e);
-    },
+    }
 
     /**
      * This function is called in a few different cases:
@@ -193,9 +201,7 @@
       const trimmedInput = this._inputVal && this._inputVal.trim();
       if (trimmedInput) {
         const predefinedOpOnlyQuery = SEARCH_OPERATORS_WITH_NEGATIONS.some(
-            op => {
-              return op.endsWith(':') && op === trimmedInput;
-            }
+            op => op.endsWith(':') && op === trimmedInput
         );
         if (predefinedOpOnlyQuery) {
           return;
@@ -204,7 +210,7 @@
           detail: {inputVal: this._inputVal},
         }));
       }
-    },
+    }
 
     /**
      * Determine what array of possible suggestions should be provided
@@ -245,9 +251,9 @@
         default:
           return Promise.resolve(SEARCH_OPERATORS_WITH_NEGATIONS
               .filter(operator => operator.includes(input))
-              .map(operator => ({text: operator})));
+              .map(operator => { return {text: operator}; }));
       }
-    },
+    }
 
     /**
      * Get the sorted, pruned list of suggestions for the current search query.
@@ -291,7 +297,7 @@
                   };
                 });
           });
-    },
+    }
 
     _handleSearch(e) {
       const keyboardEvent = this.getKeyboardEvent(e);
@@ -301,6 +307,8 @@
       e.preventDefault();
       this.$.searchInput.focus();
       this.$.searchInput.selectAll();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrSearchBar.is, GrSearchBar);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index a4927c3..9d60952 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-search-bar</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -59,11 +59,9 @@
       assert.equal(element._inputVal, 'foo');
     });
 
-    getActiveElement = () => {
-      return document.activeElement.shadowRoot ?
-        document.activeElement.shadowRoot.activeElement :
-        document.activeElement;
-    };
+    getActiveElement = () => (document.activeElement.shadowRoot ?
+      document.activeElement.shadowRoot.activeElement :
+      document.activeElement);
 
     test('enter in search input fires event', done => {
       element.addEventListener('handle-search', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index 2446486..664d59f 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -21,52 +21,60 @@
   const SELF_EXPRESSION = 'self';
   const ME_EXPRESSION = 'me';
 
-  Polymer({
-    is: 'gr-smart-search',
+  /**
+   * @appliesMixin Gerrit.DisplayNameMixin
+   * @extends Polymer.Element
+   */
+  class GrSmartSearch extends Polymer.mixinBehaviors( [
+    Gerrit.DisplayNameBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-smart-search'; }
 
-    properties: {
-      searchQuery: String,
-      _config: Object,
-      _projectSuggestions: {
-        type: Function,
-        value() {
-          return this._fetchProjects.bind(this);
+    static get properties() {
+      return {
+        searchQuery: String,
+        _config: Object,
+        _projectSuggestions: {
+          type: Function,
+          value() {
+            return this._fetchProjects.bind(this);
+          },
         },
-      },
-      _groupSuggestions: {
-        type: Function,
-        value() {
-          return this._fetchGroups.bind(this);
+        _groupSuggestions: {
+          type: Function,
+          value() {
+            return this._fetchGroups.bind(this);
+          },
         },
-      },
-      _accountSuggestions: {
-        type: Function,
-        value() {
-          return this._fetchAccounts.bind(this);
+        _accountSuggestions: {
+          type: Function,
+          value() {
+            return this._fetchAccounts.bind(this);
+          },
         },
-      },
-    },
+      };
+    }
 
-    behaviors: [
-      Gerrit.DisplayNameBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.$.restAPI.getConfig().then(cfg => {
         this._config = cfg;
       });
-    },
+    }
 
     _handleSearch(e) {
       const input = e.detail.inputVal;
       if (input) {
         Gerrit.Nav.navigateToSearchQuery(input);
       }
-    },
+    }
 
     _accountOrAnon(name) {
       return this.getUserName(this._serverConfig, name, false);
-    },
+    }
 
     /**
      * Fetch from the API the predicted projects.
@@ -85,9 +93,9 @@
           .then(projects => {
             if (!projects) { return []; }
             const keys = Object.keys(projects);
-            return keys.map(key => ({text: predicate + ':' + key}));
+            return keys.map(key => { return {text: predicate + ':' + key}; });
           });
-    },
+    }
 
     /**
      * Fetch from the API the predicted groups.
@@ -107,9 +115,9 @@
           .then(groups => {
             if (!groups) { return []; }
             const keys = Object.keys(groups);
-            return keys.map(key => ({text: predicate + ':' + key}));
+            return keys.map(key => { return {text: predicate + ':' + key}; });
           });
-    },
+    }
 
     /**
      * Fetch from the API the predicted accounts.
@@ -129,7 +137,8 @@
           .then(accounts => {
             if (!accounts) { return []; }
             return this._mapAccountsHelper(accounts, predicate);
-          }).then(accounts => {
+          })
+          .then(accounts => {
             // When the expression supplied is a beginning substring of 'self',
             // add it as an autocomplete option.
             if (SELF_EXPRESSION.startsWith(expression)) {
@@ -141,15 +150,19 @@
               return accounts;
             }
           });
-    },
+    }
 
     _mapAccountsHelper(accounts, predicate) {
-      return accounts.map(account => ({
-        label: account.name || '',
-        text: account.email ?
-          `${predicate}:${account.email}` :
-          `${predicate}:"${this._accountOrAnon(account)}"`,
-      }));
-    },
-  });
+      return accounts.map(account => {
+        return {
+          label: account.name || '',
+          text: account.email ?
+            `${predicate}:${account.email}` :
+            `${predicate}:"${this._accountOrAnon(account)}"`,
+        };
+      });
+    }
+  }
+
+  customElements.define(GrSmartSearch.is, GrSmartSearch);
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
index a70eb7c..917c550 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-smart-search</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -48,7 +48,6 @@
       sandbox.restore();
     });
 
-
     test('Autocompletes accounts', () => {
       sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
         Promise.resolve([
@@ -72,12 +71,15 @@
           },
         ])
       );
-      element._fetchAccounts('owner', 's').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-        assert.deepEqual(s[1], {text: 'owner:self'});
-      }).then(() => element._fetchAccounts('owner', 'selfs')).then(s => {
-        assert.notEqual(s[0], {text: 'owner:self'});
-      });
+      element._fetchAccounts('owner', 's')
+          .then(s => {
+            assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+            assert.deepEqual(s[1], {text: 'owner:self'});
+          })
+          .then(() => element._fetchAccounts('owner', 'selfs'))
+          .then(s => {
+            assert.notEqual(s[0], {text: 'owner:self'});
+          });
     });
 
     test('Inserts me as option when valid', () => {
@@ -89,12 +91,15 @@
           },
         ])
       );
-      return element._fetchAccounts('owner', 'm').then(s => {
-        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-        assert.deepEqual(s[1], {text: 'owner:me'});
-      }).then(() => element._fetchAccounts('owner', 'meme')).then(s => {
-        assert.notEqual(s[0], {text: 'owner:me'});
-      });
+      return element._fetchAccounts('owner', 'm')
+          .then(s => {
+            assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+            assert.deepEqual(s[1], {text: 'owner:me'});
+          })
+          .then(() => element._fetchAccounts('owner', 'meme'))
+          .then(s => {
+            assert.notEqual(s[0], {text: 'owner:me'});
+          });
     });
 
     test('Autocompletes groups', () => {
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
index 4cf35f1..1cd042a 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.html
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.html b/polygerrit-ui/app/elements/custom-light-theme_test.html
index e346af5..0cabd09 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.html
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app-it_test</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.html b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.html
new file mode 100644
index 0000000..1569094
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.html
@@ -0,0 +1,91 @@
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-dialog/gr-dialog.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+
+<dom-module id="gr-apply-fix-dialog">
+  <template>
+    <style include="shared-styles">
+      gr-diff {
+        --content-width: 90vw;
+      }
+      .diffContainer {
+        padding: var(--spacing-l) 0;
+        border-bottom: 1px solid var(--border-color);
+      }
+      .file-name {
+        display: block;
+        padding: var(--spacing-s) var(--spacing-l);
+        background-color: var(--background-color-secondary);
+        border-bottom: 1px solid var(--border-color);
+      }
+      .fixActions {
+        display: flex;
+        justify-content: flex-end;
+      }
+      gr-button {
+        margin-left: var(--spacing-m);
+      }
+      .fix-picker {
+        display: flex;
+        align-items: center;
+        margin-right: var(--spacing-l);
+      }
+    </style>
+    <gr-overlay id="applyFixOverlay" with-backdrop>
+      <gr-dialog
+        id="applyFixDialog"
+        on-confirm="_handleApplyFix"
+        confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
+        disabled="[[_isApplyFixLoading]]"
+        on-cancel="onCancel">
+        <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
+        <div slot="main">
+          <template is="dom-repeat" items="[[_currentPreviews]]">
+            <div class="file-name">
+              <span>[[item.filepath]]</span>
+            </div>
+            <div class="diffContainer">
+              <gr-diff
+              prefs="[[overridePartialPrefs(prefs)]]"
+              change-num="[[changeNum]]"
+              path="[[item.filepath]]"
+              diff="[[item.preview]]"></gr-diff>
+            </div>
+          </template>
+        </div>
+        <div slot="footer" class="fix-picker" hidden$="[[hasSingleFix(_fixSuggestions)]]">
+          <span>Suggested fix [[addOneTo(_selectedFixIdx)]] of [[_fixSuggestions.length]]</span>
+          <gr-button on-click="_onPrevFixClick" disabled$="[[_noPrevFix(_selectedFixIdx)]]">
+            <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+          </gr-button>
+          <gr-button on-click="_onNextFixClick" disabled$="[[_noNextFix(_selectedFixIdx)]]">
+            <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+          </gr-button>
+        </div>
+      </gr-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-apply-fix-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
new file mode 100644
index 0000000..7561f1b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+  'use strict';
+  Polymer({
+    is: 'gr-apply-fix-dialog',
+
+    properties: {
+      // Diff rendering preference API response.
+      prefs: Array,
+      // ChangeInfo API response object.
+      change: Object,
+      changeNum: String,
+      _patchNum: Number,
+      // robot ID associated with a robot comment.
+      _robotId: String,
+      // Selected FixSuggestionInfo entity from robot comment API response.
+      _currentFix: Object,
+      // Flattened /preview API response DiffInfo map object.
+      _currentPreviews: {type: Array, value: () => []},
+      // FixSuggestionInfo entities from robot comment API response.
+      _fixSuggestions: Array,
+      _isApplyFixLoading: {
+        type: Boolean,
+        value: false,
+      },
+      // Index of currently showing suggested fix.
+      _selectedFixIdx: Number,
+    },
+
+    behaviors: [
+      Gerrit.FireBehavior,
+    ],
+
+    /**
+     * Given robot comment CustomEvent objevt, fetch diffs associated
+     * with first robot comment suggested fix and open dialog.
+     *
+     * @param {*} e CustomEvent to be passed from gr-comment with
+     * robot comment detail.
+     * @return {Promise<undefined>} Promise that resolves either when all
+     * preview diffs are fetched or no fix suggestions in custom event detail.
+     */
+    open(e) {
+      this._patchNum = e.detail.patchNum;
+      this._fixSuggestions = e.detail.comment.fix_suggestions;
+      this._robotId = e.detail.comment.robot_id;
+      if (this._fixSuggestions == null || this._fixSuggestions.length == 0) {
+        return Promise.resolve();
+      }
+      this._selectedFixIdx = 0;
+      const promises = [];
+      promises.push(
+          this._showSelectedFixSuggestion(this._fixSuggestions[0]),
+          this.$.applyFixOverlay.open()
+      );
+      return Promise.all(promises)
+          .then(() => {
+            // ensures gr-overlay repositions overlay in center
+            this.$.applyFixOverlay.fire('iron-resize');
+          });
+    },
+
+    _showSelectedFixSuggestion(fixSuggestion) {
+      this._currentFix = fixSuggestion;
+      return this._fetchFixPreview(fixSuggestion.fix_id);
+    },
+
+    _fetchFixPreview(fixId) {
+      return this.$.restAPI
+          .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
+          .then(res => {
+            if (res != null) {
+              const previews = Object.keys(res).map(key => {
+                return {filepath: key, preview: res[key]};
+              });
+              this._currentPreviews = previews;
+            }
+          })
+          .catch(err => {
+            this._close();
+            this.dispatchEvent(new CustomEvent('show-error', {
+              bubbles: true,
+              composed: true,
+              detail: {message: `Error generating fix preview: ${err}`},
+            }));
+          });
+    },
+
+    hasSingleFix(_fixSuggestions) {
+      return (_fixSuggestions || {}).length === 1;
+    },
+
+    overridePartialPrefs(prefs) {
+      // generate a smaller gr-diff than fullscreen for dialog
+      return Object.assign({}, prefs, {line_length: 50});
+    },
+
+    onCancel(e) {
+      if (e) {
+        e.stopPropagation();
+      }
+      this._close();
+    },
+
+    addOneTo(_selectedFixIdx) {
+      return _selectedFixIdx + 1;
+    },
+
+    _onPrevFixClick(e) {
+      if (e) e.stopPropagation();
+      if (this._selectedFixIdx >= 1 && this._fixSuggestions != null) {
+        this._selectedFixIdx -= 1;
+        return this._showSelectedFixSuggestion(
+            this._fixSuggestions[this._selectedFixIdx]);
+      }
+    },
+
+    _onNextFixClick(e) {
+      if (e) e.stopPropagation();
+      if (this._selectedFixIdx < this._fixSuggestions.length &&
+        this._fixSuggestions != null) {
+        this._selectedFixIdx += 1;
+        return this._showSelectedFixSuggestion(
+            this._fixSuggestions[this._selectedFixIdx]);
+      }
+    },
+
+    _noPrevFix(_selectedFixIdx) {
+      return _selectedFixIdx === 0;
+    },
+
+    _noNextFix(_selectedFixIdx) {
+      if (this._fixSuggestions == null) return true;
+      return _selectedFixIdx === this._fixSuggestions.length - 1;
+    },
+
+    _close() {
+      this._currentFix = {};
+      this._currentPreviews = [];
+      this._isApplyFixLoading = false;
+
+      this.dispatchEvent(new CustomEvent('close-fix-preview', {
+        bubbles: true,
+        composed: true,
+      }));
+      this.$.applyFixOverlay.close();
+    },
+
+    _getApplyFixButtonLabel(isLoading) {
+      return isLoading ? 'Saving...' : 'Apply Fix';
+    },
+
+    _handleApplyFix(e) {
+      if (e) {
+        e.stopPropagation();
+      }
+      if (this._currentFix == null || this._currentFix.fix_id == null) {
+        return;
+      }
+      this._isApplyFixLoading = true;
+      return this.$.restAPI.applyFixSuggestion(this.changeNum, this._patchNum,
+          this._currentFix.fix_id).then(res => {
+        Gerrit.Nav.navigateToChange(this.change, 'edit', this._patchNum);
+        this._close();
+      })
+          .catch(err => {
+            this.dispatchEvent(new CustomEvent('show-error', {
+              bubbles: true,
+              composed: true,
+              detail: {message: `Error applying fix suggestion: ${err}`},
+            }));
+          });
+    },
+
+    getFixDescription(currentFix) {
+      return currentFix != null && currentFix.description ?
+        currentFix.description : '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
new file mode 100644
index 0000000..5a2193d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
@@ -0,0 +1,191 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2019 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the 'License');
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an 'AS IS' BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<meta name='viewport' content='width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes'>
+<title>gr-apply-fix-dialog</title>
+<script src='/test/common-test-setup.js'></script>
+<script src='/bower_components/webcomponentsjs/custom-elements-es5-adapter.js'></script>
+
+<script src='/bower_components/webcomponentsjs/webcomponents-lite.js'></script>
+<script src='/bower_components/web-component-tester/browser.js'></script>
+<link rel='import' href='../../../test/common-test-setup.html' />
+
+<link rel='import' href='./gr-apply-fix-dialog.html'>
+
+<script>void (0);</script>
+
+<test-fixture id='basic'>
+  <template>
+    <gr-apply-fix-dialog></gr-apply-fix-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-apply-fix-dialog tests', () => {
+    let element;
+    let sandbox;
+    const ROBOT_COMMENT = {
+      robot_id: 'robot_1',
+      fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.changeNum = '1';
+      element._patchNum = 2;
+      element.change = {
+        _number: '1',
+        project: 'project',
+      };
+      element.prefs = {
+        font_size: 12,
+        line_length: 100,
+        tab_size: 4,
+      };
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('dialog opens fetch and sets previews', done => {
+      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+          .returns(Promise.resolve({
+            f1: {
+              meta_a: {},
+              meta_b: {},
+              content: [
+                {
+                  ab: ['loqlwkqll'],
+                },
+                {
+                  b: ['qwqqsqw'],
+                },
+                {
+                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+                },
+              ],
+            },
+            f2: {
+              meta_a: {},
+              meta_b: {},
+              content: [
+                {
+                  ab: ['eqweqweqwex'],
+                },
+                {
+                  b: ['zassdasd'],
+                },
+                {
+                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+                },
+              ],
+            },
+          }));
+      sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+      element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT}})
+          .then(() => {
+            assert.equal(element._currentFix.fix_id, 'fix_1');
+            assert.equal(element._currentPreviews.length, 2);
+            assert.equal(element._robotId, 'robot_1');
+            done();
+          });
+    });
+
+    test('preview endpoint throws error should reset dialog', done => {
+      element.addEventListener('show-error', () => {
+        assert.deepEqual(element._currentFix, {});
+        assert.equal(element._currentPreviews.length, 0);
+        done();
+      });
+      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview',
+          () => Promise.reject(new Error('backend error')));
+
+      element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT}});
+    });
+
+    test('apply fix button should call apply' +
+    'and navigate to change view', done => {
+      sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
+          .returns(Promise.resolve());
+      sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      element._currentFix = {fix_id: '123'};
+
+      element._handleApplyFix().then(() => {
+        assert.isTrue(element.$.restAPI.applyFixSuggestion
+            .calledWithExactly('1', 2, '123'));
+        assert.isTrue(Gerrit.Nav.navigateToChange.calledWithExactly({
+          _number: '1',
+          project: 'project',
+        }, 'edit', 2));
+
+        // reset gr-apply-fix-dialog and close
+        assert.deepEqual(element._currentFix, {});
+        assert.equal(element._currentPreviews.length, 0);
+        done();
+      });
+    });
+
+    test('select fix forward and back of multiple suggested fixes', done => {
+      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+          .returns(Promise.resolve({
+            f1: {
+              meta_a: {},
+              meta_b: {},
+              content: [
+                {
+                  ab: ['loqlwkqll'],
+                },
+                {
+                  b: ['qwqqsqw'],
+                },
+                {
+                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+                },
+              ],
+            },
+            f2: {
+              meta_a: {},
+              meta_b: {},
+              content: [
+                {
+                  ab: ['eqweqweqwex'],
+                },
+                {
+                  b: ['zassdasd'],
+                },
+                {
+                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+                },
+              ],
+            },
+          }));
+      sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+      element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT}})
+          .then(() => {
+            element._onNextFixClick();
+            assert.equal(element._currentFix.fix_id, 'fix_2');
+            element._onPrevFixClick();
+            assert.equal(element._currentFix.fix_id, 'fix_1');
+            done();
+          });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
index b7994e6..4abdb615 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock.js
@@ -17,16 +17,20 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'comment-api-mock',
+  class CommentApiMock extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'comment-api-mock'; }
 
-    properties: {
-      _changeComments: Object,
-    },
+    static get properties() {
+      return {
+        _changeComments: Object,
+      };
+    }
 
     loadComments() {
       return this._reloadComments();
-    },
+    }
 
     /**
      * For the purposes of the mock, _reloadDrafts is not included because its
@@ -35,16 +39,16 @@
      * file simpler by just using _reloadComments here instead.
      */
     _reloadDraftsWithCallback(e) {
-      return this._reloadComments().then(() => {
-        return e.detail.resolve();
-      });
-    },
+      return this._reloadComments().then(() => e.detail.resolve());
+    }
 
     _reloadComments() {
       return this.$.commentAPI.loadAll(this._changeNum)
           .then(comments => {
             this._changeComments = this.$.commentAPI._changeComments;
           });
-    },
-  });
+    }
+  }
+
+  customElements.define(CommentApiMock.is, CommentApiMock);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 1e8158d..490367a 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -23,11 +23,11 @@
    * Construct a change comments object, which can be data-bound to child
    * elements of that which uses the gr-comment-api.
    *
+   * @constructor
    * @param {!Object} comments
    * @param {!Object} robotComments
    * @param {!Object} drafts
    * @param {number} changeNum
-   * @constructor
    */
   function ChangeComments(comments, robotComments, drafts, changeNum) {
     this._comments = comments;
@@ -362,9 +362,10 @@
   };
 
   ChangeComments.prototype._sortComments = function(comments) {
-    return comments.slice(0).sort((c1, c2) => {
-      return util.parseDate(c1.updated) - util.parseDate(c2.updated);
-    });
+    return comments.slice(0)
+        .sort(
+            (c1, c2) => util.parseDate(c1.updated) - util.parseDate(c2.updated)
+        );
   };
 
   /**
@@ -463,20 +464,29 @@
         this._isInRevisionOfPatchRange(comment, range);
   };
 
-  Polymer({
-    is: 'gr-comment-api',
+  /**
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @extends Polymer.Element
+   */
+  class GrCommentApi extends Polymer.mixinBehaviors( [
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-comment-api'; }
 
-    properties: {
-      _changeComments: Object,
-    },
+    static get properties() {
+      return {
+        _changeComments: Object,
+      };
+    }
 
-    listeners: {
-      'reload-drafts': 'reloadDrafts',
-    },
-
-    behaviors: [
-      Gerrit.PatchSetBehavior,
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('reload-drafts',
+          changeNum => this.reloadDrafts(changeNum));
+    }
 
     /**
      * Load all comments (with drafts and robot comments) for the given change
@@ -497,7 +507,7 @@
             robotComments, drafts, changeNum);
         return this._changeComments;
       });
-    },
+    }
 
     /**
      * Re-initialize _changeComments with a new ChangeComments object, that
@@ -516,6 +526,8 @@
             this._changeComments.robotComments, drafts, changeNum);
         return this._changeComments;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCommentApi.is, GrCommentApi);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index 47181f9..6f96a38 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -131,19 +131,21 @@
 
       test('with loadAll first', done => {
         assert.isNotOk(element._changeComments);
-        element.loadAll().then(() => {
-          assert.isOk(element._changeComments);
-          assert.equal(commentStub.callCount, 1);
-          assert.equal(robotCommentStub.callCount, 1);
-          assert.equal(draftStub.callCount, 1);
-          return element.reloadDrafts();
-        }).then(() => {
-          assert.isOk(element._changeComments);
-          assert.equal(commentStub.callCount, 1);
-          assert.equal(robotCommentStub.callCount, 1);
-          assert.equal(draftStub.callCount, 2);
-          done();
-        });
+        element.loadAll()
+            .then(() => {
+              assert.isOk(element._changeComments);
+              assert.equal(commentStub.callCount, 1);
+              assert.equal(robotCommentStub.callCount, 1);
+              assert.equal(draftStub.callCount, 1);
+              return element.reloadDrafts();
+            })
+            .then(() => {
+              assert.isOk(element._changeComments);
+              assert.equal(commentStub.callCount, 1);
+              assert.equal(robotCommentStub.callCount, 1);
+              assert.equal(draftStub.callCount, 2);
+              done();
+            });
       });
     });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
index 3d9c172..1bc4674 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
@@ -24,36 +24,41 @@
     [Gerrit.CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
   ]);
 
-  Polymer({
-    is: 'gr-coverage-layer',
+  /** @extends Polymer.Element */
+  class GrCoverageLayer extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-coverage-layer'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * Must be sorted by code_range.start_line.
        * Must only contain ranges that match the side.
        *
        * @type {!Array<!Gerrit.CoverageRange>}
        */
-      coverageRanges: Array,
-      side: String,
+        coverageRanges: Array,
+        side: String,
 
-      /**
-       * We keep track of the line number from the previous annotate() call,
-       * and also of the index of the coverage range that had matched.
-       * annotate() calls are coming in with increasing line numbers and
-       * coverage ranges are sorted by line number. So this is a very simple
-       * and efficient way for finding the coverage range that matches a given
-       * line number.
-       */
-      _lineNumber: {
-        type: Number,
-        value: 0,
-      },
-      _index: {
-        type: Number,
-        value: 0,
-      },
-    },
+        /**
+         * We keep track of the line number from the previous annotate() call,
+         * and also of the index of the coverage range that had matched.
+         * annotate() calls are coming in with increasing line numbers and
+         * coverage ranges are sorted by line number. So this is a very simple
+         * and efficient way for finding the coverage range that matches a given
+         * line number.
+         */
+        _lineNumber: {
+          type: Number,
+          value: 0,
+        },
+        _index: {
+          type: Number,
+          value: 0,
+        },
+      };
+    }
 
     /**
      * Layer method to add annotations to a line.
@@ -102,6 +107,8 @@
         lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
         return;
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCoverageLayer.is, GrCoverageLayer);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
index 45a67e1..99c583d 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-coverage-layer</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index 6f5a8d3..1f96cec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -20,6 +20,7 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
+  /** @constructor */
   function GrDiffBuilderBinary(diff, prefs, outputEl) {
     GrDiffBuilder.call(this, diff, prefs, outputEl);
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 283b7fd..a7a29db 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -24,6 +24,7 @@
   // arbitrary JavaScript.
   const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
 
+  /** @constructor */
   function GrDiffBuilderImage(diff, prefs, outputEl, baseImage, revisionImage) {
     GrDiffBuilderSideBySide.call(this, diff, prefs, outputEl, []);
     this._baseImage = baseImage;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index bb590ba..153b7b7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,6 +20,7 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
+  /** @constructor */
   function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
     GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
index 19e017d..4c44414 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>GrDiffBuilderUnified</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index 9446845..40fbe3c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -77,7 +77,6 @@
 
         properties: {
           diff: Object,
-          diffPath: String,
           changeNum: String,
           patchNum: String,
           viewMode: String,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index a4a75ca..33cd4cb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -230,7 +230,7 @@
   GrDiffBuilder.prototype.getSectionsByLineRange = function(
       startLine, endLine, opt_side) {
     return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
-        group => { return group.element; });
+        group => group.element);
   };
 
   GrDiffBuilder.prototype._createContextControl = function(section, line) {
@@ -294,6 +294,7 @@
       e.detail = {
         groups,
         section,
+        numLines,
       };
       // Let it bubble up the DOM tree.
     });
@@ -308,10 +309,16 @@
       td.classList.add(opt_class);
     }
 
-    if (line.type === GrDiffLine.Type.REMOVE) {
-      td.setAttribute('aria-label', `${number} removed`);
-    } else if (line.type === GrDiffLine.Type.ADD) {
-      td.setAttribute('aria-label', `${number} added`);
+    // Add aria-labels for valid line numbers.
+    // For unified diff, this method will be called with number set to 0 for
+    // the empty line number column for added/removed lines. This should not
+    // be announced to the screenreader.
+    if (number > 0) {
+      if (line.type === GrDiffLine.Type.REMOVE) {
+        td.setAttribute('aria-label', `${number} removed`);
+      } else if (line.type === GrDiffLine.Type.ADD) {
+        td.setAttribute('aria-label', `${number} added`);
+      }
     }
 
     if (line.type === GrDiffLine.Type.BLANK) {
@@ -350,7 +357,9 @@
     }
 
     for (const layer of this.layers) {
-      layer.annotate(contentText, lineNumberEl, line);
+      if (typeof layer.annotate == 'function') {
+        layer.annotate(contentText, lineNumberEl, line);
+      }
     }
 
     td.appendChild(contentText);
@@ -567,6 +576,10 @@
         isStartOfRange ? 'startOfRange' : '');
     const shaNode = this._createElement('span', 'sha');
     shaNode.innerText = commit.id.substr(0, 7);
+    shaNode.onclick = function() {
+      location.href = '/q/' + shaNode.innerText;
+    };
+
     blameNode.appendChild(shaNode);
     blameNode.append(` on ${date} by ${commit.author}`);
     return blameNode;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 3fdf242..867a067 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-builder</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -171,7 +171,6 @@
           '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
     });
 
-
     test('line_length ignored if line_wrapping is true', () => {
       builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
       const text = 'a'.repeat(51);
@@ -257,8 +256,8 @@
     });
 
     test('tab wrapper style', () => {
-      const pattern = new RegExp('^<span class="style-scope gr-diff tab" '
-          + 'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
+      const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
+        'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
 
       for (const size of [1, 3, 8, 55]) {
         const html = builder._getTabWrapper(size).outerHTML;
@@ -323,7 +322,8 @@
       const lineNumberEl = document.createElement('td');
 
       function slice(str, start, end) {
-        return Array.from(str).slice(start, end).join('');
+        return Array.from(str).slice(start, end)
+            .join('');
       }
 
       setup(() => {
@@ -812,9 +812,8 @@
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
-            section.textContent = group.lines.reduce((acc, line) => {
-              return acc + line.text;
-            }, '');
+            section.textContent = group.lines
+                .reduce((acc, line) => acc + line.text, '');
             return section;
           };
           return builder;
@@ -859,7 +858,7 @@
         const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
         element.render(keyLocations, {}).then(() => {
           const firedEventTypes = dispatchEventStub.getCalls()
-              .map(c => { return c.args[0].type; });
+              .map(c => c.args[0].type);
           assert.include(firedEventTypes, 'render-start');
           assert.include(firedEventTypes, 'render-content');
           done();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 6ddb390..726ac10 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -35,92 +35,121 @@
   const LEFT_SIDE_CLASS = 'target-side-left';
   const RIGHT_SIDE_CLASS = 'target-side-right';
 
-  Polymer({
-    is: 'gr-diff-cursor',
+  /** @extends Polymer.Element */
+  class GrDiffCursor extends Polymer.mixinBehaviors([Gerrit.FireBehavior],
+      Polymer.GestureEventListeners(
+          Polymer.LegacyElementMixin(Polymer.Element))) {
+    static get is() { return 'gr-diff-cursor'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * Either DiffSides.LEFT or DiffSides.RIGHT.
        */
-      side: {
-        type: String,
-        value: DiffSides.RIGHT,
-      },
-      /** @type {!HTMLElement|undefined} */
-      diffRow: {
-        type: Object,
-        notify: true,
-        observer: '_rowChanged',
-      },
+        side: {
+          type: String,
+          value: DiffSides.RIGHT,
+        },
+        /** @type {!HTMLElement|undefined} */
+        diffRow: {
+          type: Object,
+          notify: true,
+          observer: '_rowChanged',
+        },
 
-      /**
-       * The diff views to cursor through and listen to.
-       */
-      diffs: {
-        type: Array,
-        value() { return []; },
-      },
+        /**
+         * The diff views to cursor through and listen to.
+         */
+        diffs: {
+          type: Array,
+          value() { return []; },
+        },
 
-      /**
-       * If set, the cursor will attempt to move to the line number (instead of
-       * the first chunk) the next time the diff renders. It is set back to null
-       * when used. It should be only used if you want the line to be focused
-       * after initialization of the component and page should scroll
-       * to that position. This parameter should be set at most for one gr-diff
-       * element in the page.
-       *
-       * @type {?number}
-       */
-      initialLineNumber: {
-        type: Number,
-        value: null,
-      },
+        /**
+         * If set, the cursor will attempt to move to the line number (instead of
+         * the first chunk) the next time the diff renders. It is set back to null
+         * when used. It should be only used if you want the line to be focused
+         * after initialization of the component and page should scroll
+         * to that position. This parameter should be set at most for one gr-diff
+         * element in the page.
+         *
+         * @type {?number}
+         */
+        initialLineNumber: {
+          type: Number,
+          value: null,
+        },
 
-      /**
-       * The scroll behavior for the cursor. Values are 'never' and
-       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-       * the viewport.
-       */
-      _scrollBehavior: {
-        type: String,
-        value: ScrollBehavior.KEEP_VISIBLE,
-      },
+        /**
+         * The scroll behavior for the cursor. Values are 'never' and
+         * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+         * the viewport.
+         */
+        _scrollBehavior: {
+          type: String,
+          value: ScrollBehavior.KEEP_VISIBLE,
+        },
 
-      _focusOnMove: {
-        type: Boolean,
-        value: true,
-      },
+        _focusOnMove: {
+          type: Boolean,
+          value: true,
+        },
 
-      _listeningForScroll: Boolean,
-    },
+        _listeningForScroll: Boolean,
+      };
+    }
 
-    observers: [
-      '_updateSideClass(side)',
-      '_diffsChanged(diffs.splices)',
-    ],
+    static get observers() {
+      return [
+        '_updateSideClass(side)',
+        '_diffsChanged(diffs.splices)',
+      ];
+    }
 
+    /** @override */
+    ready() {
+      super.ready();
+      Polymer.RenderStatus.afterNextRender(this, () => {
+        /*
+        This represents the diff cursor is ready for interaction coming from
+        client components. It is more then Polymer "ready" lifecycle, as no
+        "ready" events are automatically fired by Polymer, it means
+        the cursor is completely interactable - in this case attached and
+        painted on the page. We name it "ready" instead of "rendered" as the
+        long-term goal is to make gr-diff-cursor a javascript class - not a DOM
+        element with an actual lifecycle. This will be triggered only once
+        per element.
+        */
+        this.fire('ready', null, {bubbles: false});
+      });
+    }
+
+    /** @override */
     attached() {
+      super.attached();
       // Catch when users are scrolling as the view loads.
       this.listen(window, 'scroll', '_handleWindowScroll');
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
-    },
+    }
 
     moveLeft() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
         this.moveUp();
       }
-    },
+    }
 
     moveRight() {
       this.side = DiffSides.RIGHT;
       if (this._isTargetBlank()) {
         this.moveUp();
       }
-    },
+    }
 
     moveDown() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
@@ -128,7 +157,7 @@
       } else {
         this.$.cursorManager.next();
       }
-    },
+    }
 
     moveUp() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
@@ -136,30 +165,28 @@
       } else {
         this.$.cursorManager.previous();
       }
-    },
+    }
 
     moveToNextChunk(opt_clipToTop) {
       this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-          target => {
-            return target.parentNode.scrollHeight;
-          }, opt_clipToTop);
+          target => target.parentNode.scrollHeight, opt_clipToTop);
       this._fixSide();
-    },
+    }
 
     moveToPreviousChunk() {
       this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
       this._fixSide();
-    },
+    }
 
     moveToNextCommentThread() {
       this.$.cursorManager.next(this._rowHasThread.bind(this));
       this._fixSide();
-    },
+    }
 
     moveToPreviousCommentThread() {
       this.$.cursorManager.previous(this._rowHasThread.bind(this));
       this._fixSide();
-    },
+    }
 
     /**
      * @param {number} number
@@ -172,7 +199,7 @@
         this.side = side;
         this.$.cursorManager.setCursor(row);
       }
-    },
+    }
 
     /**
      * Get the line number element targeted by the cursor row and side.
@@ -191,7 +218,7 @@
       }
 
       return this.diffRow.querySelector(lineElSelector);
-    },
+    }
 
     getTargetDiffElement() {
       if (!this.diffRow) return null;
@@ -203,12 +230,17 @@
         return hostOwner.host;
       }
       return null;
-    },
+    }
 
     moveToFirstChunk() {
       this.$.cursorManager.moveToStart();
       this.moveToNextChunk(true);
-    },
+    }
+
+    moveToLastChunk() {
+      this.$.cursorManager.moveToEnd();
+      this.moveToPreviousChunk();
+    }
 
     reInitCursor() {
       this._updateStops();
@@ -218,7 +250,7 @@
       } else {
         this.moveToFirstChunk();
       }
-    },
+    }
 
     _handleWindowScroll() {
       if (this._listeningForScroll) {
@@ -226,7 +258,7 @@
         this._focusOnMove = false;
         this._listeningForScroll = false;
       }
-    },
+    }
 
     handleDiffUpdate() {
       this._updateStops();
@@ -241,11 +273,24 @@
       this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
       this._focusOnMove = true;
       this._listeningForScroll = false;
-    },
+    }
 
     _handleDiffRenderStart() {
       this._listeningForScroll = true;
-    },
+    }
+
+    createCommentInPlace() {
+      const diffWithRangeSelected = this.diffs
+          .find(diff => diff.isRangeSelected());
+      if (diffWithRangeSelected) {
+        diffWithRangeSelected.createRangeComment();
+      } else {
+        const line = this.getTargetLineElement();
+        if (line) {
+          this.getTargetDiffElement().addDraftAtLine(line);
+        }
+      }
+    }
 
     /**
      * Get an object describing the location of the cursor. Such as
@@ -278,7 +323,7 @@
         leftSide: cell.matches('.left'),
         number: parseInt(number, 10),
       };
-    },
+    }
 
     _getViewMode() {
       if (!this.diffRow) {
@@ -290,24 +335,24 @@
       } else {
         return DiffViewMode.UNIFIED;
       }
-    },
+    }
 
     _rowHasSide(row) {
       const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
           ' + .content';
       return !!row.querySelector(selector);
-    },
+    }
 
     _isFirstRowOfChunk(row) {
       const parentClassList = row.parentNode.classList;
       return parentClassList.contains('section') &&
           parentClassList.contains('delta') &&
           !row.previousSibling;
-    },
+    }
 
     _rowHasThread(row) {
       return row.querySelector('.thread-group');
-    },
+    }
 
     /**
      * If we jumped to a row where there is no content on the current side then
@@ -319,7 +364,7 @@
         this.side = this.side === DiffSides.LEFT ?
           DiffSides.RIGHT : DiffSides.LEFT;
       }
-    },
+    }
 
     _isTargetBlank() {
       if (!this.diffRow) {
@@ -329,14 +374,14 @@
       const actions = this._getActionsForRow();
       return (this.side === DiffSides.LEFT && !actions.left) ||
           (this.side === DiffSides.RIGHT && !actions.right);
-    },
+    }
 
     _rowChanged(newRow, oldRow) {
       if (oldRow) {
         oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
       }
       this._updateSideClass();
-    },
+    }
 
     _updateSideClass() {
       if (!this.diffRow) {
@@ -346,11 +391,11 @@
           this.diffRow);
       this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
           this.diffRow);
-    },
+    }
 
     _isActionType(type) {
       return type !== 'blank' && type !== 'contextControl';
-    },
+    }
 
     _getActionsForRow() {
       const actions = {left: false, right: false};
@@ -361,18 +406,16 @@
             this.diffRow.getAttribute('right-type'));
       }
       return actions;
-    },
+    }
 
     _getStops() {
       return this.diffs.reduce(
-          (stops, diff) => {
-            return stops.concat(diff.getCursorStops());
-          }, []);
-    },
+          (stops, diff) => stops.concat(diff.getCursorStops()), []);
+    }
 
     _updateStops() {
       this.$.cursorManager.stops = this._getStops();
-    },
+    }
 
     /**
      * Setup and tear down on-render listeners for any diffs that are added or
@@ -409,7 +452,7 @@
               'render-content', 'handleDiffUpdate');
         }
       }
-    },
+    }
 
     _findRowByNumberAndFile(targetNumber, side, opt_path) {
       let stops;
@@ -426,6 +469,8 @@
           return stops[i];
         }
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffCursor.is, GrDiffCursor);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 1c1100d..24ca3d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-cursor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -42,6 +42,12 @@
   </template>
 </test-fixture>
 
+<test-fixture id="empty">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-diff-cursor tests', () => {
     let sandbox;
@@ -102,6 +108,18 @@
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
 
+    test('moveToLastChunk', () => {
+      const chunks = Array.from(Polymer.dom(diffElement.root).querySelectorAll(
+          '.section.delta'));
+      assert.isAbove(chunks.length, 1);
+      assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+
+      cursorElement.moveToLastChunk();
+
+      assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
+          chunks.length - 1);
+    });
+
     test('cursor scroll behavior', () => {
       cursorElement._handleDiffRenderStart();
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
@@ -259,6 +277,70 @@
       );
     });
 
+    suite('createCommentInPlace', () => {
+      setup(() => {
+        diffElement.loggedIn = true;
+      });
+
+      test('adds new draft for selected line on the left', done => {
+        cursorElement.moveToLineNumber(2, 'left');
+        diffElement.addEventListener('create-comment', e => {
+          const {lineNum, range, side, patchNum} = e.detail;
+          assert.equal(lineNum, 2);
+          assert.equal(range, undefined);
+          assert.equal(patchNum, 1);
+          assert.equal(side, 'left');
+          done();
+        });
+        cursorElement.createCommentInPlace();
+      });
+
+      test('adds draft for selected line on the right', done => {
+        cursorElement.moveToLineNumber(4, 'right');
+        diffElement.addEventListener('create-comment', e => {
+          const {lineNum, range, side, patchNum} = e.detail;
+          assert.equal(lineNum, 4);
+          assert.equal(range, undefined);
+          assert.equal(patchNum, 2);
+          assert.equal(side, 'right');
+          done();
+        });
+        cursorElement.createCommentInPlace();
+      });
+
+      test('createCommentInPlace creates comment for range if selected', done => {
+        const someRange = {
+          start_line: 2,
+          start_character: 3,
+          end_line: 6,
+          end_character: 1,
+        };
+        diffElement.$.highlights.selectedRange = {
+          side: 'right',
+          range: someRange,
+        };
+        diffElement.addEventListener('create-comment', e => {
+          const {lineNum, range, side, patchNum} = e.detail;
+          assert.equal(lineNum, 6);
+          assert.equal(range, someRange);
+          assert.equal(patchNum, 2);
+          assert.equal(side, 'right');
+          done();
+        });
+        cursorElement.createCommentInPlace();
+      });
+
+      test('createCommentInPlace ignores call if nothing is selected', () => {
+        const createRangeCommentStub = sandbox.stub(diffElement,
+            'createRangeComment');
+        const addDraftAtLineStub = sandbox.stub(diffElement, 'addDraftAtLine');
+        cursorElement.diffRow = undefined;
+        cursorElement.createCommentInPlace();
+        assert.isFalse(createRangeCommentStub.called);
+        assert.isFalse(addDraftAtLineStub.called);
+      });
+    });
+
     test('getAddress', () => {
       // It should initialize to the first chunk: line 5 of the revision.
       assert.deepEqual(cursorElement.getAddress(),
@@ -306,4 +388,24 @@
       });
     });
   });
+
+  suite('gr-diff-cursor event tests', () => {
+    let sandbox;
+    let someEmptyDiv;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      someEmptyDiv = fixture('empty');
+    });
+
+    teardown(() => sandbox.restore());
+
+    test('ready is fired after component is rendered', done => {
+      const cursorElement = document.createElement('gr-diff-cursor');
+      cursorElement.addEventListener('ready', () => {
+        done();
+      });
+      someEmptyDiv.appendChild(cursorElement);
+    });
+  });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
index ab2e65d..14c8800 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -44,6 +44,65 @@
     },
 
     /**
+     * Annotates the [offset, offset+length) text segment in the parent with the
+     * element definition provided as arguments.
+     *
+     * @param {!Element} parent the node whose contents will be annotated.
+     * @param {number} offset the 0-based offset from which the annotation will
+     *   start.
+     * @param {number} length of the annotated text.
+     * @param {GrAnnotation.ElementSpec} elementSpec the spec to create the
+     *   annotating element.
+     */
+    annotateWithElement(parent, offset, length, {tagName, attributes = {}}) {
+      let childNodes;
+
+      if (parent instanceof Element) {
+        childNodes = Array.from(parent.childNodes);
+      } else if (parent instanceof Text) {
+        childNodes = [parent];
+        parent = parent.parentNode;
+      } else {
+        return;
+      }
+
+      const nestedNodes = [];
+      for (let node of childNodes) {
+        const initialNodeLength = this.getLength(node);
+        // If the current node is completely before the offset.
+        if (offset > 0 && initialNodeLength <= offset) {
+          offset -= initialNodeLength;
+          continue;
+        }
+
+        if (offset > 0) {
+          node = this.splitNode(node, offset);
+          offset = 0;
+        }
+        if (this.getLength(node) > length) {
+          this.splitNode(node, length);
+        }
+        nestedNodes.push(node);
+
+        length -= this.getLength(node);
+        if (!length) break;
+      }
+
+      const wrapper = document.createElement(tagName);
+      const sanitizer = window.Polymer.sanitizeDOMValue;
+      for (const [name, value] of Object.entries(attributes)) {
+        wrapper.setAttribute(
+            name, sanitizer ?
+              sanitizer(value, name, 'attribute', wrapper) :
+              value);
+      }
+      for (const inner of nestedNodes) {
+        parent.replaceChild(wrapper, inner);
+        wrapper.appendChild(inner);
+      }
+    },
+
+    /**
      * Surrounds the element's text at specified range in an ANNOTATION_TAG
      * element. If the element has child elements, the range is split and
      * applied as deeply as possible.
@@ -211,5 +270,15 @@
     },
   };
 
+  /**
+   * Data used to construct an element.
+   *
+   * @typedef {{
+   *   tagName: string,
+   *   attributes: (!Object<string, *>|undefined)
+   * }}
+   */
+  GrAnnotation.ElementSpec;
+
   window.GrAnnotation = GrAnnotation;
 })(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index c1bf3ed..c7fe997 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -40,13 +40,19 @@
     let str;
     let parent;
     let textNode;
+    let sandbox;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       parent = fixture('basic');
       textNode = parent.childNodes[0];
       str = textNode.textContent;
     });
 
+    teardown(() => {
+      sandbox.restore();
+    });
+
     test('_annotateText Case 1', () => {
       GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
 
@@ -191,5 +197,95 @@
       assert(node.textContent, helloString);
       assert(tail.textContent, unicodeString);
     });
+
+    suite('annotateWithElement', () => {
+      const fullText = '01234567890123456789';
+      let mockSanitize;
+
+      setup(() => {
+        mockSanitize = sandbox.spy(window.Polymer, 'sanitizeDOMValue');
+      });
+
+      test('annotates when fully contained', () => {
+        const length = 10;
+        const container = document.createElement('div');
+        container.textContent = fullText;
+        GrAnnotation.annotateWithElement(
+            container, 1, length, {tagName: 'test-wrapper'});
+
+        assert.equal(
+            container.innerHTML,
+            '0<test-wrapper>1234567890</test-wrapper>123456789');
+      });
+
+      test('annotates when spanning multiple nodes', () => {
+        const length = 10;
+        const container = document.createElement('div');
+        container.textContent = fullText;
+        GrAnnotation.annotateElement(container, 5, length, 'testclass');
+        GrAnnotation.annotateWithElement(
+            container, 1, length, {tagName: 'test-wrapper'});
+
+        assert.equal(
+            container.innerHTML,
+            '0' +
+            '<test-wrapper>' +
+            '1234' +
+            '<hl class="testclass">567890</hl>' +
+            '</test-wrapper>' +
+            '<hl class="testclass">1234</hl>' +
+            '56789');
+      });
+
+      test('annotates text node', () => {
+        const length = 10;
+        const container = document.createElement('div');
+        container.textContent = fullText;
+        GrAnnotation.annotateWithElement(
+            container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
+
+        assert.equal(
+            container.innerHTML,
+            '0<test-wrapper>1234567890</test-wrapper>123456789');
+      });
+
+      test('handles zero-length nodes', () => {
+        const container = document.createElement('div');
+        container.appendChild(document.createTextNode('0123456789'));
+        container.appendChild(document.createElement('span'));
+        container.appendChild(document.createTextNode('0123456789'));
+        GrAnnotation.annotateWithElement(
+            container, 1, 10, {tagName: 'test-wrapper'});
+
+        assert.equal(
+            container.innerHTML,
+            '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
+      });
+
+      test('sets sanitized attributes', () => {
+        const container = document.createElement('div');
+        container.textContent = fullText;
+        const attributes = {
+          'href': 'foo',
+          'data-foo': 'bar',
+          'class': 'hello world',
+        };
+        GrAnnotation.annotateWithElement(
+            container, 1, length, {tagName: 'test-wrapper', attributes});
+        assert(mockSanitize.calledWith(
+            'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
+        assert(mockSanitize.calledWith(
+            'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
+        assert(mockSanitize.calledWith(
+            'hello world',
+            'class',
+            'attribute',
+            sinon.match.instanceOf(Element)));
+        const el = container.querySelector('test-wrapper');
+        assert.equal(el.getAttribute('href'), 'foo');
+        assert.equal(el.getAttribute('data-foo'), 'bar');
+        assert.equal(el.getAttribute('class'), 'hello world');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 0c6f4a3..e524cd8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -17,33 +17,57 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-diff-highlight',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrDiffHighlight extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-diff-highlight'; }
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {!Array<!Gerrit.HoveredRange>} */
-      commentRanges: {
-        type: Array,
-        notify: true,
-      },
-      loggedIn: Boolean,
-      /**
-       * querySelector can return null, so needs to be nullable.
-       *
-       * @type {?HTMLElement}
-       * */
-      _cachedDiffBuilder: Object,
-    },
+        commentRanges: {
+          type: Array,
+          notify: true,
+        },
+        loggedIn: Boolean,
+        /**
+         * querySelector can return null, so needs to be nullable.
+         *
+         * @type {?HTMLElement}
+         * */
+        _cachedDiffBuilder: Object,
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+        /**
+         * Which range is currently selected by the user.
+         * Stored in order to add a range-based comment
+         * later.
+         * undefined if no range is selected.
+         *
+         * @type {{side: string, range: Gerrit.Range}|undefined}
+         */
+        selectedRange: {
+          type: Object,
+          notify: true,
+        },
+      };
+    }
 
-    listeners: {
-      'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
-      'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
-      'create-range-comment': '_createRangeComment',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('comment-thread-mouseleave',
+          e => this._handleCommentThreadMouseleave(e));
+      this.addEventListener('comment-thread-mouseenter',
+          e => this._handleCommentThreadMouseenter(e));
+      this.addEventListener('create-comment-requested',
+          e => this._handleRangeCommentRequest(e));
+    }
 
     get diffBuilder() {
       if (!this._cachedDiffBuilder) {
@@ -51,12 +75,7 @@
             Polymer.dom(this).querySelector('gr-diff-builder');
       }
       return this._cachedDiffBuilder;
-    },
-
-
-    isRangeSelected() {
-      return !!this.$$('gr-selection-action-box');
-    },
+    }
 
     /**
      * Determines side/line/range for a DOM selection and shows a tooltip.
@@ -85,7 +104,7 @@
       this.debounce(
           'selectionChange', () => this._handleSelection(selection, isMouseUp),
           10);
-    },
+    }
 
     _getThreadEl(e) {
       const path = Polymer.dom(e).path || [];
@@ -93,7 +112,7 @@
         if (pathEl.classList.contains('comment-thread')) return pathEl;
       }
       return null;
-    },
+    }
 
     _handleCommentThreadMouseenter(e) {
       const threadEl = this._getThreadEl(e);
@@ -102,7 +121,7 @@
       if (index !== undefined) {
         this.set(['commentRanges', index, 'hovering'], true);
       }
-    },
+    }
 
     _handleCommentThreadMouseleave(e) {
       const threadEl = this._getThreadEl(e);
@@ -111,7 +130,7 @@
       if (index !== undefined) {
         this.set(['commentRanges', index, 'hovering'], false);
       }
-    },
+    }
 
     _indexForThreadEl(threadEl) {
       const side = threadEl.getAttribute('comment-side');
@@ -120,7 +139,7 @@
       if (!range) return undefined;
 
       return this._indexOfCommentRange(side, range);
-    },
+    }
 
     _indexOfCommentRange(side, range) {
       function rangesEqual(a, b) {
@@ -138,7 +157,7 @@
 
       return this.commentRanges.findIndex(commentRange =>
         commentRange.side === side && rangesEqual(commentRange.range, range));
-    },
+    }
 
     /**
      * Get current normalized selection.
@@ -177,7 +196,7 @@
           end: endRange.end,
         };
       }
-    },
+    }
 
     /**
      * Normalize a specific DOM Range.
@@ -192,7 +211,7 @@
         end: this._normalizeSelectionSide(
             range.endContainer, range.endOffset),
       }, domRange);
-    },
+    }
 
     /**
      * Adjust triple click selection for the whole line.
@@ -233,7 +252,7 @@
         };
       }
       return range;
-    },
+    }
 
     /**
      * Convert DOM Range selection to concrete numbers (line, column, side).
@@ -290,7 +309,7 @@
         line,
         column,
       };
-    },
+    }
 
     /**
      * The only line in which add a comment tooltip is cut off is the first
@@ -306,7 +325,7 @@
       }
       actionBox.positionBelow = true;
       actionBox.placeBelow(range);
-    },
+    }
 
     _isRangeValid(range) {
       if (!range || !range.start || !range.end) {
@@ -320,7 +339,7 @@
         return false;
       }
       return true;
-    },
+    }
 
     _handleSelection(selection, isMouseUp) {
       const normalizedRange = this._getNormalizedRange(selection);
@@ -353,12 +372,12 @@
         // is empty to see that it's at the end of a line.
         const content = domRange.cloneContents().querySelector('.contentText');
         if (isMouseUp && this._getLength(content) === 0) {
-          this.fire('create-range-comment', {side: start.side, range: {
+          this._fireCreateRangeComment(start.side, {
             start_line: start.line,
             start_character: 0,
             end_line: start.line,
             end_character: start.column,
-          }});
+          });
         }
         return;
       }
@@ -369,13 +388,15 @@
         const root = Polymer.dom(this.root);
         root.insertBefore(actionBox, root.firstElementChild);
       }
-      actionBox.range = {
-        start_line: start.line,
-        start_character: start.column,
-        end_line: end.line,
-        end_character: end.column,
+      this.selectedRange = {
+        range: {
+          start_line: start.line,
+          start_character: start.column,
+          end_line: end.line,
+          end_character: end.column,
+        },
+        side: start.side,
       };
-      actionBox.side = start.side;
       if (start.line === end.line) {
         this._positionActionBox(actionBox, start.line, domRange);
       } else if (start.node instanceof Text) {
@@ -390,18 +411,29 @@
       } else {
         this._positionActionBox(actionBox, start.line, start.node);
       }
-    },
+    }
 
-    _createRangeComment(e) {
+    _fireCreateRangeComment(side, range) {
+      this.fire('create-range-comment', {side, range});
       this._removeActionBox();
-    },
+    }
+
+    _handleRangeCommentRequest(e) {
+      e.stopPropagation();
+      if (!this.selectedRange) {
+        throw Error('Selected Range is needed for new range comment!');
+      }
+      const {side, range} = this.selectedRange;
+      this._fireCreateRangeComment(side, range);
+    }
 
     _removeActionBox() {
+      this.selectedRange = undefined;
       const actionBox = this.$$('gr-selection-action-box');
       if (actionBox) {
         Polymer.dom(this.root).removeChild(actionBox);
       }
-    },
+    }
 
     _convertOffsetToColumn(el, offset) {
       if (el instanceof Element && el.classList.contains('content')) {
@@ -417,7 +449,7 @@
         }
       }
       return offset;
-    },
+    }
 
     /**
      * Traverse Element from right to left, call callback for each node.
@@ -442,7 +474,7 @@
         }
         node = nextNode;
       }
-    },
+    }
 
     /**
      * Get length of a node. If the node is a content node, then only give the
@@ -457,6 +489,8 @@
       } else {
         return GrAnnotation.getLength(node);
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffHighlight.is, GrDiffHighlight);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index c929e1e..c1a696c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-highlight</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -228,13 +228,25 @@
         assert.isFalse(element.set.called);
       });
 
-      test('on create-range-comment action box is removed', () => {
+      test(`create-range-comment for range when create-comment-requested
+            is fired`, () => {
         sandbox.stub(element, '_removeActionBox');
-        element.fire('create-range-comment', {
-          comment: {
-            range: {},
+        element.selectedRange = {
+          side: 'left',
+          range: {
+            start_line: 7,
+            start_character: 11,
+            end_line: 24,
+            end_character: 42,
           },
+        };
+        const requestEvent = new CustomEvent('create-comment-requested');
+        let createRangeEvent;
+        element.addEventListener('create-range-comment', e => {
+          createRangeEvent = e;
         });
+        element.dispatchEvent(requestEvent);
+        assert.deepEqual(element.selectedRange, createRangeEvent.detail);
         assert.isTrue(element._removeActionBox.called);
       });
     });
@@ -271,14 +283,6 @@
         element._handleSelection(selection);
       };
 
-      const getActionRange = () =>
-        Polymer.dom(element.root).querySelector(
-            'gr-selection-action-box').range;
-
-      const getActionSide = () =>
-        Polymer.dom(element.root).querySelector(
-            'gr-selection-action-box').side;
-
       const getLineElByChild = node => {
         const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
         return stubs && stubs.lineEl;
@@ -329,14 +333,14 @@
         sandbox.spy(element, '_positionActionBox');
         emulateSelection(content.firstChild, 5, content.firstChild, 12);
         const actionBox = element.$$('gr-selection-action-box');
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 138,
           start_character: 5,
           end_line: 138,
           end_character: 12,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
         assert.notOk(actionBox.positionBelow);
       });
 
@@ -346,16 +350,15 @@
         sandbox.spy(element, '_positionActionBox');
         emulateSelection(
             startContent.firstChild, 10, endContent.lastChild, 7);
-        assert.isTrue(element.isRangeSelected());
         const actionBox = element.$$('gr-selection-action-box');
-
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 119,
           start_character: 10,
           end_line: 120,
           end_character: 36,
         });
-        assert.equal(getActionSide(), 'right');
+        assert.equal(side, 'right');
         assert.notOk(actionBox.positionBelow);
       });
 
@@ -374,15 +377,16 @@
         const getRangeAtStub = sandbox.stub();
         getRangeAtStub
             .onFirstCall().returns(startRange)
-            .onSecondCall().returns(endRange);
+            .onSecondCall()
+            .returns(endRange);
         const selection = {
           rangeCount: 2,
           getRangeAt: getRangeAtStub,
           removeAllRanges: sandbox.stub(),
         };
         element._handleSelection(selection);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 119,
           start_character: 10,
           end_line: 120,
@@ -394,43 +398,43 @@
         const startContent = stubContent(119, 'right');
         const endContent = stubContent(120, 'right');
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 119,
           start_character: 10,
           end_line: 120,
           end_character: 2,
         });
-        assert.equal(getActionSide(), 'right');
+        assert.equal(side, 'right');
       });
 
       test('collapsed', () => {
         const content = stubContent(138, 'left');
         emulateSelection(content.firstChild, 5, content.firstChild, 5);
         assert.isOk(window.getSelection().getRangeAt(0).startContainer);
-        assert.isFalse(element.isRangeSelected());
+        assert.isFalse(!!element.selectedRange);
       });
 
       test('starts inside hl', () => {
         const content = stubContent(140, 'left');
         const hl = content.querySelector('.foo');
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 8,
           end_line: 140,
           end_character: 23,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
       });
 
       test('ends inside hl', () => {
         const content = stubContent(140, 'left');
         const hl = content.querySelector('.bar');
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 18,
           end_line: 140,
@@ -442,14 +446,14 @@
         const content = stubContent(140, 'left');
         const hl = content.querySelectorAll('hl')[4];
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 2,
           end_line: 140,
           end_character: 61,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
       });
 
       test('starts outside of diff', () => {
@@ -458,21 +462,21 @@
 
         emulateSelection(contentTd.previousElementSibling, 0,
             contentText.firstChild, 2);
-        assert.isFalse(element.isRangeSelected());
+        assert.isFalse(!!element.selectedRange);
       });
 
       test('ends outside of diff', () => {
         const content = stubContent(140, 'left');
         emulateSelection(content.nextElementSibling.firstChild, 2,
             content.firstChild, 2);
-        assert.isFalse(element.isRangeSelected());
+        assert.isFalse(!!element.selectedRange);
       });
 
       test('starts and ends on different sides', () => {
         const startContent = stubContent(140, 'left');
         const endContent = stubContent(130, 'right');
         emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-        assert.isFalse(element.isRangeSelected());
+        assert.isFalse(!!element.selectedRange);
       });
 
       test('starts in comment thread element', () => {
@@ -481,14 +485,14 @@
             '.comment-thread');
         const endContent = stubContent(141, 'left');
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 83,
           end_line: 141,
           end_character: 4,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
       });
 
       test('ends in comment thread element', () => {
@@ -496,14 +500,14 @@
         const comment = content.parentElement.querySelector(
             '.comment-thread');
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 4,
           end_line: 140,
           end_character: 83,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
       });
 
       test('starts in context element', () => {
@@ -512,7 +516,7 @@
         const content = stubContent(146, 'right');
         emulateSelection(contextControl, 0, content.firstChild, 7);
         // TODO (viktard): Select nearest line.
-        assert.isFalse(element.isRangeSelected());
+        assert.isFalse(!!element.selectedRange);
       });
 
       test('ends in context element', () => {
@@ -521,35 +525,35 @@
         const content = stubContent(141, 'left');
         emulateSelection(content.firstChild, 2, contextControl, 1);
         // TODO (viktard): Select nearest line.
-        assert.isFalse(element.isRangeSelected());
+        assert.isFalse(!!element.selectedRange);
       });
 
       test('selection containing context element', () => {
         const startContent = stubContent(130, 'right');
         const endContent = stubContent(146, 'right');
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 130,
           start_character: 3,
           end_line: 146,
           end_character: 14,
         });
-        assert.equal(getActionSide(), 'right');
+        assert.equal(side, 'right');
       });
 
       test('ends at a tab', () => {
         const content = stubContent(140, 'left');
         emulateSelection(
             content.firstChild, 1, content.querySelector('span'), 0);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 1,
           end_line: 140,
           end_character: 51,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
       });
 
       test('starts at a tab', () => {
@@ -557,14 +561,14 @@
         emulateSelection(
             content.querySelectorAll('hl')[3], 0,
             content.querySelectorAll('span')[1].nextSibling, 1);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 140,
           start_character: 51,
           end_line: 140,
           end_character: 71,
         });
-        assert.equal(getActionSide(), 'left');
+        assert.equal(side, 'left');
       });
 
       test('properly accounts for syntax highlighting', () => {
@@ -593,14 +597,14 @@
         const startContent = stubContent(119, 'right');
         const endContent = stubContent(120, 'right');
         emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 119,
           start_character: 0,
           end_line: 119,
           end_character: element._getLength(startContent),
         });
-        assert.equal(getActionSide(), 'right');
+        assert.equal(side, 'right');
       });
 
       test('_fixTripleClickSelection empty line', () => {
@@ -608,14 +612,14 @@
         const endContent = stubContent(165, 'left');
         emulateSelection(startContent.firstChild, 0,
             endContent.parentElement.previousElementSibling, 0);
-        assert.isTrue(element.isRangeSelected());
-        assert.deepEqual(getActionRange(), {
+        const {range, side} = element.selectedRange;
+        assert.deepEqual(range, {
           start_line: 146,
           start_character: 0,
           end_line: 146,
           end_character: 84,
         });
-        assert.equal(getActionSide(), 'right');
+        assert.equal(side, 'right');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
index cb482b2..3b6c41c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -102,9 +102,9 @@
      * @return {number} The length of the text.
      */
     _getLength(node) {
-      return node
-        ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
-        : 0;
+      return node ?
+        node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length :
+        0;
     },
   };
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index b1ceb7e..cb23308 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -69,14 +69,24 @@
   };
 
   /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   */
+  /**
    * Wrapper around gr-diff.
    *
    * Webcomponent fetching diffs and related data from restAPI and passing them
    * to the presentational gr-diff for rendering.
+   *
+   * @extends Polymer.Element
    */
-  Polymer({
-    is: 'gr-diff-host',
-
+  class GrDiffHost extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-diff-host'; }
     /**
      * Fired when the user selects a line.
      *
@@ -95,189 +105,202 @@
      * @event diff-comments-modified
      */
 
-    properties: {
-      changeNum: String,
-      noAutoRender: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      patchRange: Object,
-      path: String,
-      prefs: {
-        type: Object,
-      },
-      projectName: String,
-      displayLine: {
-        type: Boolean,
-        value: false,
-      },
-      isImageDiff: {
-        type: Boolean,
-        computed: '_computeIsImageDiff(diff)',
-        notify: true,
-      },
-      commitRange: Object,
-      filesWeblinks: {
-        type: Object,
-        value() {
-          return {};
+    static get properties() {
+      return {
+        changeNum: String,
+        noAutoRender: {
+          type: Boolean,
+          value: false,
         },
-        notify: true,
-      },
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      noRenderOnPrefsChange: {
-        type: Boolean,
-        value: false,
-      },
-      comments: {
-        type: Object,
-        observer: '_commentsChanged',
-      },
-      lineWrapping: {
-        type: Boolean,
-        value: false,
-      },
-      viewMode: {
-        type: String,
-        value: DiffViewMode.SIDE_BY_SIDE,
-      },
+        /** @type {?} */
+        patchRange: Object,
+        path: String,
+        prefs: {
+          type: Object,
+        },
+        projectName: String,
+        displayLine: {
+          type: Boolean,
+          value: false,
+        },
+        isImageDiff: {
+          type: Boolean,
+          computed: '_computeIsImageDiff(diff)',
+          notify: true,
+        },
+        commitRange: Object,
+        filesWeblinks: {
+          type: Object,
+          value() {
+            return {};
+          },
+          notify: true,
+        },
+        hidden: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+        noRenderOnPrefsChange: {
+          type: Boolean,
+          value: false,
+        },
+        comments: {
+          type: Object,
+          observer: '_commentsChanged',
+        },
+        lineWrapping: {
+          type: Boolean,
+          value: false,
+        },
+        viewMode: {
+          type: String,
+          value: DiffViewMode.SIDE_BY_SIDE,
+        },
 
-      /**
-       * Special line number which should not be collapsed into a shared region.
-       *
-       * @type {{
-       *  number: number,
-       *  leftSide: {boolean}
-       * }|null}
-       */
-      lineOfInterest: Object,
+        /**
+         * Special line number which should not be collapsed into a shared region.
+         *
+         * @type {{
+         *  number: number,
+         *  leftSide: {boolean}
+         * }|null}
+         */
+        lineOfInterest: Object,
 
-      /**
-       * If the diff fails to load, show the failure message in the diff rather
-       * than bubbling the error up to the whole page. This is useful for when
-       * loading inline diffs because one diff failing need not mark the whole
-       * page with a failure.
-       */
-      showLoadFailure: Boolean,
+        /**
+         * If the diff fails to load, show the failure message in the diff rather
+         * than bubbling the error up to the whole page. This is useful for when
+         * loading inline diffs because one diff failing need not mark the whole
+         * page with a failure.
+         */
+        showLoadFailure: Boolean,
 
-      isBlameLoaded: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeIsBlameLoaded(_blame)',
-      },
+        isBlameLoaded: {
+          type: Boolean,
+          notify: true,
+          computed: '_computeIsBlameLoaded(_blame)',
+        },
 
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
 
-      _loading: {
-        type: Boolean,
-        value: false,
-      },
+        _loading: {
+          type: Boolean,
+          value: false,
+        },
 
-      /** @type {?string} */
-      _errorMessage: {
-        type: String,
-        value: null,
-      },
+        /** @type {?string} */
+        _errorMessage: {
+          type: String,
+          value: null,
+        },
 
-      /** @type {?Object} */
-      _baseImage: Object,
-      /** @type {?Object} */
-      _revisionImage: Object,
-      /**
-       * This is a DiffInfo object.
-       */
-      diff: {
-        type: Object,
-        notify: true,
-      },
+        /** @type {?Object} */
+        _baseImage: Object,
+        /** @type {?Object} */
+        _revisionImage: Object,
+        /**
+         * This is a DiffInfo object.
+         */
+        diff: {
+          type: Object,
+          notify: true,
+        },
 
-      /** @type {?Object} */
-      _blame: {
-        type: Object,
-        value: null,
-      },
+        /** @type {?Object} */
+        _blame: {
+          type: Object,
+          value: null,
+        },
 
-      /**
-       * @type {!Array<!Gerrit.CoverageRange>}
-       */
-      _coverageRanges: {
-        type: Array,
-        value: () => [],
-      },
+        /**
+         * @type {!Array<!Gerrit.CoverageRange>}
+         */
+        _coverageRanges: {
+          type: Array,
+          value: () => [],
+        },
 
-      _loadedWhitespaceLevel: String,
+        _loadedWhitespaceLevel: String,
 
-      _parentIndex: {
-        type: Number,
-        computed: '_computeParentIndex(patchRange.*)',
-      },
+        _parentIndex: {
+          type: Number,
+          computed: '_computeParentIndex(patchRange.*)',
+        },
 
-      _syntaxHighlightingEnabled: {
-        type: Boolean,
-        computed:
+        _syntaxHighlightingEnabled: {
+          type: Boolean,
+          computed:
           '_isSyntaxHighlightingEnabled(prefs.*, diff)',
-      },
+        },
 
-      _layers: {
-        type: Array,
-        value: [],
-      },
-    },
+        _layers: {
+          type: Array,
+          value: [],
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-    ],
-
-    listeners: {
-      // These are named inconsistently for a reason:
-      // The create-comment event is fired to indicate that we should
-      // create a comment.
-      // The comment-* events are just notifying that the comments did already
-      // change in some way, and that we should update any models we may want
-      // to keep in sync.
-      'create-comment': '_handleCreateComment',
-      'comment-discard': '_handleCommentDiscard',
-      'comment-update': '_handleCommentUpdate',
-      'comment-save': '_handleCommentSave',
-
-      'render-start': '_handleRenderStart',
-      'render-content': '_handleRenderContent',
-
-      'normalize-range': '_handleNormalizeRange',
-    },
-
-    observers: [
-      '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
+    static get observers() {
+      return [
+        '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
           ' noRenderOnPrefsChange)',
-      '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
-    ],
+        '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
+      ];
+    }
 
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener(
+          // These are named inconsistently for a reason:
+          // The create-comment event is fired to indicate that we should
+          // create a comment.
+          // The comment-* events are just notifying that the comments did already
+          // change in some way, and that we should update any models we may want
+          // to keep in sync.
+          'create-comment',
+          e => this._handleCreateComment(e));
+      this.addEventListener('comment-discard',
+          e => this._handleCommentDiscard(e));
+      this.addEventListener('comment-update',
+          e => this._handleCommentUpdate(e));
+      this.addEventListener('comment-save',
+          e => this._handleCommentSave(e));
+      this.addEventListener('render-start',
+          () => this._handleRenderStart());
+      this.addEventListener('render-content',
+          () => this._handleRenderContent());
+      this.addEventListener('normalize-range',
+          event => this._handleNormalizeRange(event));
+      this.addEventListener('diff-context-expanded',
+          event => this._handleDiffContextExpanded(event));
+    }
+
+    /** @override */
     ready() {
+      super.ready();
       if (this._canReload()) {
         this.reload();
       }
-    },
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
       });
-    },
+    }
 
     /**
-     * @param {boolean=} haveParamsChanged ends reporting events that started
-     * on location change.
+     * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
+     * signal to report metrics event that started on location change.
      * @return {!Promise}
      **/
-    reload(haveParamsChanged) {
+    reload(shouldReportMetric) {
       this._loading = true;
       this._errorMessage = null;
       const whitespaceLevel = this._getIgnoreWhitespace();
@@ -285,31 +308,18 @@
       const layers = [this.$.syntaxLayer];
       // Get layers from plugins (if any).
       for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-          this.diffPath, this.changeNum, this.patchNum)) {
+          this.path, this.changeNum, this.patchNum)) {
         layers.push(pluginLayer);
       }
       this._layers = layers;
 
-      if (haveParamsChanged) {
+      if (shouldReportMetric) {
         // We listen on render viewport only on DiffPage (on paramsChanged)
         this._listenToViewportRender();
       }
 
       this._coverageRanges = [];
-      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
-      this.$.jsAPI.getCoverageRanges(changeNum, path, basePatchNum, patchNum).
-          then(coverageRanges => {
-            if (changeNum !== this.changeNum ||
-                path !== this.path ||
-                basePatchNum !== this.patchRange.basePatchNum ||
-                patchNum !== this.patchRange.patchNum) {
-              return;
-            }
-            this._coverageRanges = coverageRanges;
-          }).catch(err => {
-            console.warn('Loading coverage ranges failed: ', err);
-          });
-
+      this._getCoverageData();
       const diffRequest = this._getDiff()
           .then(diff => {
             this._loadedWhitespaceLevel = whitespaceLevel;
@@ -328,6 +338,8 @@
         return this._loadDiffAssets(diff);
       });
 
+      // Not waiting for coverage ranges intentionally as
+      // plugin loading should not block the content rendering
       return Promise.all([diffRequest, assetRequest])
           .then(results => {
             const diff = results[0];
@@ -337,8 +349,8 @@
             this.filesWeblinks = this._getFilesWeblinks(diff);
             return new Promise(resolve => {
               const callback = event => {
-                const needsSyntaxHighlighting = event.detail
-                      && event.detail.contentRendered;
+                const needsSyntaxHighlighting = event.detail &&
+                      event.detail.contentRendered;
                 if (needsSyntaxHighlighting) {
                   this.$.reporting.time(TimingLabel.SYNTAX);
                   this.$.syntaxLayer.process().then(() => {
@@ -351,6 +363,11 @@
                   resolve();
                 }
                 this.removeEventListener('render', callback);
+                if (shouldReportMetric) {
+                  // We report diffViewContentDisplayed only on reload caused
+                  // by params changed - expected only on Diff Page.
+                  this.$.reporting.diffViewContentDisplayed();
+                }
               };
               this.addEventListener('render', callback);
               this.diff = diff;
@@ -360,7 +377,51 @@
             console.warn('Error encountered loading diff:', err);
           })
           .then(() => { this._loading = false; });
-    },
+    }
+
+    _getCoverageData() {
+      const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
+      this.$.jsAPI.getCoverageAnnotationApi().
+          then(coverageAnnotationApi => {
+            if (!coverageAnnotationApi) return;
+            const provider = coverageAnnotationApi.getCoverageProvider();
+            return provider(changeNum, path, basePatchNum, patchNum)
+                .then(coverageRanges => {
+                  if (!coverageRanges ||
+                    changeNum !== this.changeNum ||
+                    path !== this.path ||
+                    basePatchNum !== this.patchRange.basePatchNum ||
+                    patchNum !== this.patchRange.patchNum) {
+                    return;
+                  }
+
+                  const existingCoverageRanges = this._coverageRanges;
+                  this._coverageRanges = coverageRanges;
+
+                  // Notify with existing coverage ranges
+                  // in case there is some existing coverage data that needs to be removed
+                  existingCoverageRanges.forEach(range => {
+                    coverageAnnotationApi.notify(
+                        path,
+                        range.code_range.start_line,
+                        range.code_range.end_line,
+                        range.side);
+                  });
+
+                  // Notify with new coverage data
+                  coverageRanges.forEach(range => {
+                    coverageAnnotationApi.notify(
+                        path,
+                        range.code_range.start_line,
+                        range.code_range.end_line,
+                        range.side);
+                  });
+                });
+          })
+          .catch(err => {
+            console.warn('Loading coverage ranges failed: ', err);
+          });
+    }
 
     _getFilesWeblinks(diff) {
       if (!this.commitRange) {
@@ -374,26 +435,30 @@
             this.projectName, this.commitRange.commit, this.path,
             {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
       };
-    },
+    }
 
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diff.cancel();
-    },
+    }
 
     /** @return {!Array<!HTMLElement>} */
     getCursorStops() {
       return this.$.diff.getCursorStops();
-    },
+    }
 
     /** @return {boolean} */
     isRangeSelected() {
       return this.$.diff.isRangeSelected();
-    },
+    }
+
+    createRangeComment() {
+      return this.$.diff.createRangeComment();
+    }
 
     toggleLeftDiff() {
       this.$.diff.toggleLeftDiff();
-    },
+    }
 
     /**
      * Load and display blame information for the base of the diff.
@@ -411,12 +476,12 @@
 
             this._blame = blame;
           });
-    },
+    }
 
     /** Unload blame information for the diff. */
     clearBlame() {
       this._blame = null;
-    },
+    }
 
     /**
      * The thread elements in this diff, in no particular order.
@@ -426,31 +491,31 @@
     getThreadEls() {
       return Array.from(
           Polymer.dom(this.$.diff).querySelectorAll('.comment-thread'));
-    },
+    }
 
     /** @param {HTMLElement} el */
     addDraftAtLine(el) {
       this.$.diff.addDraftAtLine(el);
-    },
+    }
 
     clearDiffContent() {
       this.$.diff.clearDiffContent();
-    },
+    }
 
     expandAllContext() {
       this.$.diff.expandAllContext();
-    },
+    }
 
     /** @return {!Promise} */
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     /** @return {boolean}} */
     _canReload() {
       return !!this.changeNum && !!this.patchRange && !!this.path &&
           !this.noAutoRender;
-    },
+    }
 
     /** @return {!Promise<!Object>} */
     _getDiff() {
@@ -466,7 +531,7 @@
             reject)
             .then(resolve);
       });
-    },
+    }
 
     _handleGetDiffError(response) {
       // Loading the diff may respond with 409 if the file is too large. In this
@@ -486,7 +551,7 @@
       }
 
       this.fire('page-error', {response});
-    },
+    }
 
     /**
      * Report info about the diff response.
@@ -527,7 +592,7 @@
         this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
             percentRebaseDelta);
       }
-    },
+    }
 
     /**
      * @param {Object} diff
@@ -544,7 +609,7 @@
         this._revisionImage = null;
         return Promise.resolve();
       }
-    },
+    }
 
     /**
      * @param {Object} diff
@@ -552,7 +617,7 @@
      */
     _computeIsImageDiff(diff) {
       return isImageDiff(diff);
-    },
+    }
 
     _commentsChanged(newComments) {
       const allComments = [];
@@ -573,7 +638,7 @@
         const threadEl = this._createThreadElement(thread);
         this._attachThreadElement(threadEl);
       }
-    },
+    }
 
     /**
      * @param {!Array<!Object>} comments
@@ -615,7 +680,7 @@
         threads.push(newThread);
       }
       return threads;
-    },
+    }
 
     /**
      * @param {Object} blame
@@ -623,7 +688,7 @@
      */
     _computeIsBlameLoaded(blame) {
       return !!blame;
-    },
+    }
 
     /**
      * @param {Object} diff
@@ -632,7 +697,7 @@
     _getImages(diff) {
       return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
           this.patchRange);
-    },
+    }
 
     /** @param {CustomEvent} e */
     _handleCreateComment(e) {
@@ -642,7 +707,7 @@
       threadEl.addOrEditDraft(lineNum, range);
 
       this.$.reporting.recordDraftInteraction();
-    },
+    }
 
     /**
      * Gets or creates a comment thread at a given location.
@@ -669,18 +734,18 @@
         this._attachThreadElement(threadEl);
       }
       return threadEl;
-    },
+    }
 
     _attachThreadElement(threadEl) {
       Polymer.dom(this.$.diff).appendChild(threadEl);
-    },
+    }
 
     _clearThreads() {
       for (const threadEl of this.getThreadEls()) {
         const parent = Polymer.dom(threadEl).parentNode;
         Polymer.dom(parent).removeChild(threadEl);
       }
-    },
+    }
 
     _createThreadElement(thread) {
       const threadEl = document.createElement('gr-comment-thread');
@@ -711,7 +776,7 @@
       };
       threadEl.addEventListener('thread-discard', threadDiscardListener);
       return threadEl;
-    },
+    }
 
     /**
      * Gets a comment thread element at a given location.
@@ -740,7 +805,7 @@
       const filteredThreadEls = this._filterThreadElsForLocation(
           this.getThreadEls(), line, commentSide).filter(matchesRange);
       return filteredThreadEls.length ? filteredThreadEls[0] : null;
-    },
+    }
 
     /**
      * @param {!Array<!HTMLElement>} threadEls
@@ -785,14 +850,14 @@
       }
       return threadEls.filter(threadEl =>
         matchers.some(matcher => matcher(threadEl)));
-    },
+    }
 
     _getIgnoreWhitespace() {
       if (!this.prefs || !this.prefs.ignore_whitespace) {
         return WHITESPACE_IGNORE_NONE;
       }
       return this.prefs.ignore_whitespace;
-    },
+    }
 
     _whitespaceChanged(
         preferredWhitespaceLevel, loadedWhitespaceLevel,
@@ -810,7 +875,7 @@
           !noRenderOnPrefsChange) {
         this.reload();
       }
-    },
+    }
 
     _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
       // Polymer 2: check for undefined
@@ -828,7 +893,7 @@
       if (!noRenderOnPrefsChange) {
         this.reload();
       }
-    },
+    }
 
     /**
      * @param {Object} patchRangeRecord
@@ -837,7 +902,7 @@
     _computeParentIndex(patchRangeRecord) {
       return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
         this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
-    },
+    }
 
     _handleCommentSave(e) {
       const comment = e.detail.comment;
@@ -845,13 +910,13 @@
       const idx = this._findDraftIndex(comment, side);
       this.set(['comments', side, idx], comment);
       this._handleCommentSaveOrDiscard();
-    },
+    }
 
     _handleCommentDiscard(e) {
       const comment = e.detail.comment;
       this._removeComment(comment);
       this._handleCommentSaveOrDiscard();
-    },
+    }
 
     /**
      * Closure annotation for Polymer.prototype.push is off. Submitted PR:
@@ -872,17 +937,17 @@
       } else { // Create new draft.
         this.push(['comments', side], comment);
       }
-    },
+    }
 
     _handleCommentSaveOrDiscard() {
       this.dispatchEvent(new CustomEvent(
           'diff-comments-modified', {bubbles: true, composed: true}));
-    },
+    }
 
     _removeComment(comment) {
       const side = comment.__commentSide;
       this._removeCommentFromSide(comment, side);
-    },
+    }
 
     _removeCommentFromSide(comment, side) {
       let idx = this._findCommentIndex(comment, side);
@@ -892,7 +957,7 @@
       if (idx !== -1) {
         this.splice('comments.' + side, idx, 1);
       }
-    },
+    }
 
     /** @return {number} */
     _findCommentIndex(comment, side) {
@@ -900,7 +965,7 @@
         return -1;
       }
       return this.comments[side].findIndex(item => item.id === comment.id);
-    },
+    }
 
     /** @return {number} */
     _findDraftIndex(comment, side) {
@@ -909,7 +974,7 @@
       }
       return this.comments[side].findIndex(
           item => item.__draftID === comment.__draftID);
-    },
+    }
 
     _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
       if (!preferenceChangeRecord ||
@@ -920,7 +985,7 @@
       }
       return !this._anyLineTooLong(diff) &&
           this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
-    },
+    }
 
     /**
      * @return {boolean} whether any of the lines in diff are longer
@@ -934,7 +999,7 @@
           (section.a || []).concat(section.b || []);
         return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
       });
-    },
+    }
 
     _listenToViewportRender() {
       const renderUpdateListener = start => {
@@ -945,22 +1010,28 @@
       };
 
       this.$.syntaxLayer.addListener(renderUpdateListener);
-    },
+    }
 
     _handleRenderStart() {
       this.$.reporting.time(TimingLabel.TOTAL);
       this.$.reporting.time(TimingLabel.CONTENT);
-    },
+    }
 
     _handleRenderContent() {
       this.$.reporting.timeEnd(TimingLabel.CONTENT);
-      this.$.reporting.diffViewContentDisplayed();
-    },
+    }
 
     _handleNormalizeRange(event) {
       this.$.reporting.reportInteraction('normalize-range',
           `Modified invalid comment range on l. ${event.detail.lineNum}` +
           ` of the ${event.detail.side} side`);
-    },
-  });
+    }
+
+    _handleDiffContextExpanded(event) {
+      this.$.reporting.reportInteraction(
+          'diff-context-expanded', event.detail.numLines);
+    }
+  }
+
+  customElements.define(GrDiffHost.is, GrDiffHost);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index 359ee8d..6d05876 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -58,7 +58,6 @@
       sandbox.restore();
     });
 
-
     suite('plugin layers', () => {
       const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
       setup(() => {
@@ -124,9 +123,8 @@
         element.comments.left.push(comment);
         comment.id = id;
         element.fire('comment-discard', {comment});
-        const drafts = element.comments.left.filter(item => {
-          return item.__draftID === draftID;
-        });
+        const drafts = element.comments.left
+            .filter(item => item.__draftID === draftID);
         assert.equal(drafts.length, 0);
         assert.isTrue(diffCommentsModifiedStub.called);
       });
@@ -146,9 +144,8 @@
         element.comments.left.push(comment);
         comment.id = id;
         element.fire('comment-save', {comment});
-        const drafts = element.comments.left.filter(item => {
-          return item.__draftID === draftID;
-        });
+        const drafts = element.comments.left
+            .filter(item => item.__draftID === draftID);
         assert.equal(drafts.length, 1);
         assert.equal(drafts[0].id, id);
         assert.isTrue(diffCommentsModifiedStub.called);
@@ -320,7 +317,7 @@
         element.patchRange = {};
         element.$.restAPI.getDiffPreferences().then(prefs => {
           element.prefs = prefs;
-          return element.reload();
+          return element.reload(true);
         });
         // Multiple cascading microtasks are scheduled.
         setTimeout(() => {
@@ -362,12 +359,14 @@
             Promise.resolve({content: []}));
         element.patchRange = {};
         let reloadComplete = false;
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          return element.reload();
-        }).then(() => {
-          reloadComplete = true;
-        });
+        element.$.restAPI.getDiffPreferences()
+            .then(prefs => {
+              element.prefs = prefs;
+              return element.reload();
+            })
+            .then(() => {
+              reloadComplete = true;
+            });
         // Multiple cascading microtasks are scheduled.
         setTimeout(() => {
           assert.isFalse(reloadComplete);
@@ -507,10 +506,10 @@
           };
           sandbox.stub(element.$.restAPI,
               'getB64FileContents',
-              (changeId, patchNum, path, opt_parentIndex) => {
-                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
-                  mockFile2);
-              });
+              (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
+                  opt_parentIndex === 1 ? mockFile1 :
+                    mockFile2)
+          );
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
           element.comments = {
@@ -1261,7 +1260,6 @@
       assert.equal(threads[0].isOnParent, false);
       assert.equal(threads[0].patchNum, 2);
 
-
       // Try to fetch a thread with a different range.
       range = {
         start_line: 1,
@@ -1382,7 +1380,8 @@
       });
 
       test('starts syntax layer processing on render event', done => {
-        sandbox.stub(element.$.syntaxLayer, 'process').returns(Promise.resolve());
+        sandbox.stub(element.$.syntaxLayer, 'process')
+            .returns(Promise.resolve());
         sandbox.stub(element.$.restAPI, 'getDiff').returns(
             Promise.resolve({content: []}));
         element.reload();
@@ -1431,5 +1430,69 @@
         assert.isFalse(element.$.syntaxLayer.enabled);
       });
     });
+
+    suite('coverage layer', () => {
+      let notifyStub;
+      setup(() => {
+        notifyStub = sinon.stub();
+        stub('gr-js-api-interface', {
+          getCoverageAnnotationApi() {
+            return Promise.resolve({
+              notify: notifyStub,
+              getCoverageProvider() {
+                return () => Promise.resolve([
+                  {
+                    type: 'COVERED',
+                    side: 'right',
+                    code_range: {
+                      start_line: 1,
+                      end_line: 2,
+                    },
+                  },
+                  {
+                    type: 'NOT_COVERED',
+                    side: 'right',
+                    code_range: {
+                      start_line: 3,
+                      end_line: 4,
+                    },
+                  },
+                ]);
+              },
+            });
+          },
+        });
+        element = fixture('basic');
+        const prefs = {
+          line_length: 10,
+          show_tabs: true,
+          tab_size: 4,
+          context: -1,
+        };
+        element.diff = {
+          content: [{
+            a: ['foo'],
+          }],
+        };
+        element.patchRange = {};
+        element.prefs = prefs;
+      });
+
+      test('getCoverageAnnotationApi should be called', done => {
+        element.reload();
+        flush(() => {
+          assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
+          done();
+        });
+      });
+
+      test('coverageRangeChanged should be called', done => {
+        element.reload();
+        flush(() => {
+          assert.equal(notifyStub.callCount, 2);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
index 88dd91a..68bca23 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
@@ -17,34 +17,39 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-diff-mode-selector',
+  /** @extends Polymer.Element */
+  class GrDiffModeSelector extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-diff-mode-selector'; }
 
-    properties: {
-      mode: {
-        type: String,
-        notify: true,
-      },
-
-      /**
-       * If set to true, the user's preference will be updated every time a
-       * button is tapped. Don't set to true if there is no user.
-       */
-      saveOnChange: {
-        type: Boolean,
-        value: false,
-      },
-
-      /** @type {?} */
-      _VIEW_MODES: {
-        type: Object,
-        readOnly: true,
-        value: {
-          SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-          UNIFIED: 'UNIFIED_DIFF',
+    static get properties() {
+      return {
+        mode: {
+          type: String,
+          notify: true,
         },
-      },
-    },
+
+        /**
+         * If set to true, the user's preference will be updated every time a
+         * button is tapped. Don't set to true if there is no user.
+         */
+        saveOnChange: {
+          type: Boolean,
+          value: false,
+        },
+
+        /** @type {?} */
+        _VIEW_MODES: {
+          type: Object,
+          readOnly: true,
+          value: {
+            SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+            UNIFIED: 'UNIFIED_DIFF',
+          },
+        },
+      };
+    }
 
     /**
      * Set the mode. If save on change is enabled also update the preference.
@@ -54,18 +59,20 @@
         this.$.restAPI.savePreferences({diff_view: newMode});
       }
       this.mode = newMode;
-    },
+    }
 
     _computeSelectedClass(diffViewMode, buttonViewMode) {
       return buttonViewMode === diffViewMode ? 'selected' : '';
-    },
+    }
 
     _handleSideBySideTap() {
       this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
-    },
+    }
 
     _handleUnifiedTap() {
       this.setMode(this._VIEW_MODES.UNIFIED);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffModeSelector.is, GrDiffModeSelector);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
index adeaa15..333a3d6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-mode-selector</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
index 6ecd4d6..6aad66c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
@@ -17,39 +17,45 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-diff-preferences-dialog',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrDiffPreferencesDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-diff-preferences-dialog'; }
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      diffPrefs: Object,
+        diffPrefs: Object,
 
-      _diffPrefsChanged: Boolean,
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+        _diffPrefsChanged: Boolean,
+      };
+    }
 
     getFocusStops() {
       return {
         start: this.$.diffPreferences.$.contextSelect,
         end: this.$.saveButton,
       };
-    },
+    }
 
     resetFocus() {
       this.$.diffPreferences.$.contextSelect.focus();
-    },
+    }
 
     _computeHeaderClass(changed) {
       return changed ? 'edited' : '';
-    },
+    }
 
     _handleCancelDiff(e) {
       e.stopPropagation();
       this.$.diffPrefsOverlay.close();
-    },
+    }
 
     open() {
       this.$.diffPrefsOverlay.open().then(() => {
@@ -57,7 +63,7 @@
         this.$.diffPrefsOverlay.setFocusStops(focusStops);
         this.resetFocus();
       });
-    },
+    }
 
     _handleSaveDiffPreferences() {
       this.$.diffPreferences.save().then(() => {
@@ -65,6 +71,8 @@
 
         this.$.diffPrefsOverlay.close();
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffPreferencesDialog.is, GrDiffPreferencesDialog);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 2cd7cd4..cdd0c7d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -63,73 +63,83 @@
    *    "expand context" widget. This may require splitting a chunk/group so
    *    that the part that is within the context or has comments is shown, while
    *    the rest is not.
+   *
+   * @extends Polymer.Element
    */
-  Polymer({
-    is: 'gr-diff-processor',
+  class GrDiffProcessor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-diff-processor'; }
 
-    properties: {
+    static get properties() {
+      return {
 
-      /**
-       * The amount of context around collapsed groups.
-       */
-      context: Number,
+        /**
+         * The amount of context around collapsed groups.
+         */
+        context: Number,
 
-      /**
-       * The array of groups output by the processor.
-       */
-      groups: {
-        type: Array,
-        notify: true,
-      },
+        /**
+         * The array of groups output by the processor.
+         */
+        groups: {
+          type: Array,
+          notify: true,
+        },
 
-      /**
-       * Locations that should not be collapsed, including the locations of
-       * comments.
-       */
-      keyLocations: {
-        type: Object,
-        value() { return {left: {}, right: {}}; },
-      },
+        /**
+         * Locations that should not be collapsed, including the locations of
+         * comments.
+         */
+        keyLocations: {
+          type: Object,
+          value() { return {left: {}, right: {}}; },
+        },
 
-      /**
-       * The maximum number of lines to process synchronously.
-       */
-      _asyncThreshold: {
-        type: Number,
-        value: 64,
-      },
+        /**
+         * The maximum number of lines to process synchronously.
+         */
+        _asyncThreshold: {
+          type: Number,
+          value: 64,
+        },
 
-      /** @type {?number} */
-      _nextStepHandle: Number,
-      /**
-       * The promise last returned from `process()` while the asynchronous
-       * processing is running - `null` otherwise. Provides a `cancel()`
-       * method that rejects it with `{isCancelled: true}`.
-       *
-       * @type {?Object}
-       */
-      _processPromise: {
-        type: Object,
-        value: null,
-      },
-      _isScrolling: Boolean,
-    },
+        /** @type {?number} */
+        _nextStepHandle: Number,
+        /**
+         * The promise last returned from `process()` while the asynchronous
+         * processing is running - `null` otherwise. Provides a `cancel()`
+         * method that rejects it with `{isCancelled: true}`.
+         *
+         * @type {?Object}
+         */
+        _processPromise: {
+          type: Object,
+          value: null,
+        },
+        _isScrolling: Boolean,
+      };
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this.listen(window, 'scroll', '_handleWindowScroll');
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.cancel();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
-    },
+    }
 
     _handleWindowScroll() {
       this._isScrolling = true;
       this.debounce('resetIsScrolling', () => {
         this._isScrolling = false;
       }, 50);
-    },
+    }
 
     /**
      * Asynchronously process the diff chunks into groups. As it processes, it
@@ -153,7 +163,6 @@
       // so finish processing.
       if (isBinary) { return Promise.resolve(); }
 
-
       this._processPromise = util.makeCancelable(
           new Promise(resolve => {
             const state = {
@@ -200,7 +209,7 @@
           }));
       return this._processPromise
           .finally(() => { this._processPromise = null; });
-    },
+    }
 
     /**
      * Cancel any jobs that are running.
@@ -213,7 +222,7 @@
       if (this._processPromise) {
         this._processPromise.cancel();
       }
-    },
+    }
 
     /**
      * Process the next uncollapsible chunk, or the next collapsible chunks.
@@ -240,15 +249,15 @@
 
       return this._processCollapsibleChunks(
           state, chunks, firstUncollapsibleChunkIndex);
-    },
+    }
 
     _linesLeft(chunk) {
       return chunk.ab || chunk.a || [];
-    },
+    }
 
     _linesRight(chunk) {
       return chunk.ab || chunk.b || [];
-    },
+    }
 
     _firstUncollapsibleChunkIndex(chunks, offset) {
       let chunkIndex = offset;
@@ -257,11 +266,11 @@
         chunkIndex++;
       }
       return chunkIndex;
-    },
+    }
 
     _isCollapsibleChunk(chunk) {
       return (chunk.ab || chunk.common) && !chunk.keyLocation;
-    },
+    }
 
     /**
      * Process a stretch of collapsible chunks.
@@ -307,7 +316,7 @@
         groups,
         newChunkIndex: firstUncollapsibleChunkIndex,
       };
-    },
+    }
 
     _commonChunkLength(chunk) {
       console.assert(chunk.ab || chunk.common);
@@ -315,7 +324,7 @@
           !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
           `common chunk needs same number of a and b lines: `, chunk);
       return this._linesLeft(chunk).length;
-    },
+    }
 
     /**
      * @param {!Array<!Object>} chunks
@@ -331,7 +340,7 @@
         offsetRight += chunkLength;
         return group;
       });
-    },
+    }
 
     /**
      * @param {!Object} chunk
@@ -347,7 +356,7 @@
       group.dueToRebase = chunk.due_to_rebase;
       group.ignoredWhitespaceOnly = chunk.common;
       return group;
-    },
+    }
 
     _linesFromChunk(chunk, offsetLeft, offsetRight) {
       if (chunk.ab) {
@@ -370,7 +379,7 @@
             chunk[DiffHighlights.ADDED]));
       }
       return lines;
-    },
+    }
 
     /**
      * @param {string} lineType (GrDiffLine.Type)
@@ -384,7 +393,7 @@
         this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
       return rows.map((row, i) => this._lineFromRow(
           lineType, offset, offset, row, i, grDiffHighlights));
-    },
+    }
 
     /**
      * @param {string} type (GrDiffLine.Type)
@@ -407,15 +416,14 @@
         line.hasIntralineInfo = false;
       }
       return line;
-    },
+    }
 
     _makeFileComments() {
       const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = GrDiffLine.FILE;
       line.afterNumber = GrDiffLine.FILE;
       return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
-    },
-
+    }
 
     /**
      * Split chunks into smaller chunks of the same kind.
@@ -457,7 +465,7 @@
         }
       }
       return newChunks;
-    },
+    }
 
     /**
      * In order to show key locations, such as comments, out of the bounds of
@@ -509,7 +517,7 @@
       }
 
       return result;
-    },
+    }
 
     /**
      * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
@@ -540,7 +548,7 @@
       }
 
       return result;
-    },
+    }
 
     _splitAtChunkEnds(lines, chunkEnds) {
       const result = [];
@@ -551,7 +559,7 @@
         lastChunkEndOffset = offset;
       }
       return result;
-    },
+    }
 
     /**
      * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
@@ -601,7 +609,7 @@
         normalized.push(lineHighlight);
       }
       return normalized;
-    },
+    }
 
     /**
      * If a group is an addition or a removal, break it down into smaller groups
@@ -632,7 +640,7 @@
             }
             return subChunk;
           });
-    },
+    }
 
     /**
      * Given an array and a size, return an array of arrays where no inner array
@@ -651,6 +659,8 @@
       const tail = array.slice(array.length - size);
 
       return this._breakdown(head, size).concat([tail]);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffProcessor.is, GrDiffProcessor);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index c04b066..8eaaa4c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-processor test</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -500,7 +500,7 @@
       test('breaks down shared chunks w/ whole-file', () => {
         const size = 120 * 2 + 5;
         const content = [{
-          ab: _.times(size, () => { return `${Math.random()}`; }),
+          ab: _.times(size, () => `${Math.random()}`),
         }];
         element.context = -1;
         const result = element._splitLargeChunks(content);
@@ -511,7 +511,7 @@
 
       test('does not break-down common chunks w/ context', () => {
         const content = [{
-          ab: _.times(75, () => { return `${Math.random()}`; }),
+          ab: _.times(75, () => `${Math.random()}`),
         }];
         element.context = 4;
         const result =
@@ -897,7 +897,7 @@
             assert.isAtMost(subResult.length, size);
           }
           const flattened = result
-              .reduce((a, b) => { return a.concat(b); }, []);
+              .reduce((a, b) => a.concat(b), []);
           assert.deepEqual(flattened, array);
         });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 5caac74..e46f959 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -30,35 +30,49 @@
 
   const getNewCache = () => { return {left: null, right: null}; };
 
-  Polymer({
-    is: 'gr-diff-selection',
+  /**
+   * @appliesMixin Gerrit.DomUtilMixin
+   * @extends Polymer.Element
+   */
+  class GrDiffSelection extends Polymer.mixinBehaviors( [
+    Gerrit.DomUtilBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-diff-selection'; }
 
-    properties: {
-      diff: Object,
-      /** @type {?Object} */
-      _cachedDiffBuilder: Object,
-      _linesCache: {
-        type: Object,
-        value: getNewCache(),
-      },
-    },
+    static get properties() {
+      return {
+        diff: Object,
+        /** @type {?Object} */
+        _cachedDiffBuilder: Object,
+        _linesCache: {
+          type: Object,
+          value: getNewCache(),
+        },
+      };
+    }
 
-    observers: [
-      '_diffChanged(diff)',
-    ],
+    static get observers() {
+      return [
+        '_diffChanged(diff)',
+      ];
+    }
 
-    listeners: {
-      copy: '_handleCopy',
-      down: '_handleDown',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('copy',
+          e => this._handleCopy(e));
+      Polymer.Gestures.addListener(this, 'down',
+          e => this._handleDown(e));
+    }
 
-    behaviors: [
-      Gerrit.DomUtilBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.classList.add(SelectionClass.RIGHT);
-    },
+    }
 
     get diffBuilder() {
       if (!this._cachedDiffBuilder) {
@@ -66,11 +80,11 @@
             Polymer.dom(this).querySelector('gr-diff-builder');
       }
       return this._cachedDiffBuilder;
-    },
+    }
 
     _diffChanged() {
       this._linesCache = getNewCache();
-    },
+    }
 
     _handleDownOnRangeComment(node) {
       if (node &&
@@ -85,7 +99,7 @@
         return true;
       }
       return false;
-    },
+    }
 
     _handleDown(e) {
       // Handle the down event on comment thread in Polymer 2
@@ -115,7 +129,7 @@
       }
 
       this._setClasses(targetClasses);
-    },
+    }
 
     /**
      * Set the provided list of classes on the element, to the exclusion of all
@@ -139,11 +153,11 @@
           this.classList.add(_class);
         }
       }
-    },
+    }
 
     _getCopyEventTarget(e) {
       return Polymer.dom(e).rootTarget;
-    },
+    }
 
     /**
      * Utility function to determine whether an element is a descendant of
@@ -156,7 +170,7 @@
     _elementDescendedFromClass(element, className) {
       return this.descendedFromClass(element, className,
           this.diffBuilder.diffElement);
-    },
+    }
 
     _handleCopy(e) {
       let commentSelected = false;
@@ -176,18 +190,23 @@
         e.clipboardData.setData('Text', text);
         e.preventDefault();
       }
-    },
+    }
 
-    /**
-     * For Polymer 2, use shadowRoot.getSelection instead.
-     */
     _getSelection() {
-      const diffHost = util.querySelector(document.body, 'gr-diff');
-      const selection = diffHost &&
-        diffHost.shadowRoot &&
-        diffHost.shadowRoot.getSelection();
-      return selection ? selection: window.getSelection();
-    },
+      const diffHosts = util.querySelectorAll(document.body, 'gr-diff');
+      if (!diffHosts.length) return window.getSelection();
+
+      const curDiffHost = diffHosts.find(diffHost => {
+        if (!diffHost || !diffHost.shadowRoot) return false;
+        const selection = diffHost.shadowRoot.getSelection();
+        // Pick the one with valid selection:
+        // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
+        return selection && selection.type !== 'None';
+      });
+
+      return curDiffHost ?
+        curDiffHost.shadowRoot.getSelection(): window.getSelection();
+    }
 
     /**
      * Get the text of the current selection. If commentSelected is
@@ -226,7 +245,7 @@
 
       return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
           range.endOffset, side);
-    },
+    }
 
     /**
      * Query the diff object for the selected lines.
@@ -248,7 +267,7 @@
         lines[0] = lines[0].substring(startOffset);
       }
       return lines.join('\n');
-    },
+    }
 
     /**
      * Query the diff object for the lines from a particular side.
@@ -271,7 +290,7 @@
       }
       this._linesCache[side] = lines;
       return lines;
-    },
+    }
 
     /**
      * Query the diffElement for comments and check whether they lie inside the
@@ -309,7 +328,7 @@
       }
 
       return content.join('\n');
-    },
+    }
 
     /**
      * Given a DOM node, a selection, and a selection range, recursively get all
@@ -339,6 +358,8 @@
         }
       }
       return text;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffSelection.is, GrDiffSelection);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index 0f5c6dd..6fe319d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-selection</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -375,7 +375,6 @@
             'his is a differ');
       });
 
-
       test('multi level element as startContainer of range', () => {
         range.setStart(nodes[2].childNodes[1], 0);
         range.setEnd(nodes[2].childNodes[2], 7);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 40a02a3..7275ae5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -37,6 +37,7 @@
 <link rel="import" href="../../shared/revision-info/revision-info.html">
 <link rel="import" href="../gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
+<link rel="import" href="../gr-apply-fix-dialog/gr-apply-fix-dialog.html">
 <link rel="import" href="../gr-diff-host/gr-diff-host.html">
 <link rel="import" href="../gr-diff-mode-selector/gr-diff-mode-selector.html">
 <link rel="import" href="../gr-diff-preferences-dialog/gr-diff-preferences-dialog.html">
@@ -277,7 +278,7 @@
           </span>
         </div>
         <div class="rightControls">
-          <span class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff)]]">
+          <span class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]">
             <gr-button
                 link
                 disabled="[[_isBlameLoading]]"
@@ -343,6 +344,12 @@
         on-comment-anchor-tap="_onLineSelected"
         on-line-selected="_onLineSelected">
     </gr-diff-host>
+    <gr-apply-fix-dialog
+      id="applyFixDialog"
+      prefs="[[_prefs]]"
+      change="[[_change]]"
+      change-num="[[_changeNum]]">
+    </gr-apply-fix-dialog>
     <gr-diff-preferences-dialog
         id="diffPreferencesDialog"
         diff-prefs="{{_prefs}}"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index a3ddaf7..5087d74 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -33,9 +33,24 @@
     UNIFIED: 'UNIFIED_DIFF',
   };
 
-  Polymer({
-    is: 'gr-diff-view',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrDiffView extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.PathListBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-diff-view'; }
     /**
      * Fired when the title of the page should change.
      *
@@ -48,154 +63,152 @@
      * @event show-alert
      */
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /**
-       * @type {{ diffMode: (string|undefined) }}
-       */
-      changeViewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_changeViewStateChanged',
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      /** @type {?} */
-      _patchRange: Object,
-      /** @type {?} */
-      _commitRange: Object,
-      /**
-       * @type {{
-       *  subject: string,
-       *  project: string,
-       *  revisions: string,
-       * }}
-       */
-      _change: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _changeNum: String,
-      /**
-       * This is a DiffInfo object.
-       * This is retrieved and owned by a child component.
-       */
-      _diff: Object,
-      // An array specifically formatted to be used in a gr-dropdown-list
-      // element for selected a file to view.
-      _formattedFiles: {
-        type: Array,
-        computed: '_formatFilesForDropdown(_fileList, _patchRange.patchNum, ' +
-            '_changeComments)',
-      },
-      // An sorted array of files, as returned by the rest API.
-      _fileList: {
-        type: Array,
-        value() { return []; },
-      },
-      _path: {
-        type: String,
-        observer: '_pathChanged',
-      },
-      _fileNum: {
-        type: Number,
-        computed: '_computeFileNum(_path, _formattedFiles)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _prefs: Object,
-      _localPrefs: Object,
-      _projectConfig: Object,
-      _userPrefs: Object,
-      _diffMode: {
-        type: String,
-        computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-      },
-      _isImageDiff: Boolean,
-      _filesWeblinks: Object,
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
+        },
+        /**
+         * @type {{ diffMode: (string|undefined) }}
+         */
+        changeViewState: {
+          type: Object,
+          notify: true,
+          value() { return {}; },
+          observer: '_changeViewStateChanged',
+        },
+        disableDiffPrefs: {
+          type: Boolean,
+          value: false,
+        },
+        _diffPrefsDisabled: {
+          type: Boolean,
+          computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+        },
+        /** @type {?} */
+        _patchRange: Object,
+        /** @type {?} */
+        _commitRange: Object,
+        /**
+         * @type {{
+         *  subject: string,
+         *  project: string,
+         *  revisions: string,
+         * }}
+         */
+        _change: Object,
+        /** @type {?} */
+        _changeComments: Object,
+        _changeNum: String,
+        /**
+         * This is a DiffInfo object.
+         * This is retrieved and owned by a child component.
+         */
+        _diff: Object,
+        // An array specifically formatted to be used in a gr-dropdown-list
+        // element for selected a file to view.
+        _formattedFiles: {
+          type: Array,
+          computed: '_formatFilesForDropdown(_fileList, ' +
+            '_patchRange.patchNum, _changeComments)',
+        },
+        // An sorted array of files, as returned by the rest API.
+        _fileList: {
+          type: Array,
+          value() { return []; },
+        },
+        _path: {
+          type: String,
+          observer: '_pathChanged',
+        },
+        _fileNum: {
+          type: Number,
+          computed: '_computeFileNum(_path, _formattedFiles)',
+        },
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _prefs: Object,
+        _localPrefs: Object,
+        _projectConfig: Object,
+        _userPrefs: Object,
+        _diffMode: {
+          type: String,
+          computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+        },
+        _isImageDiff: Boolean,
+        _filesWeblinks: Object,
 
-      /**
-       * Map of paths in the current change and patch range that have comments
-       * or drafts or robot comments.
-       */
-      _commentMap: Object,
+        /**
+         * Map of paths in the current change and patch range that have comments
+         * or drafts or robot comments.
+         */
+        _commentMap: Object,
 
-      _commentsForDiff: Object,
+        _commentsForDiff: Object,
 
-      /**
-       * Object to contain the path of the next and previous file in the current
-       * change and patch range that has comments.
-       */
-      _commentSkips: {
-        type: Object,
-        computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-      },
-      _panelFloatingDisabled: {
-        type: Boolean,
-        value: () => { return window.PANEL_FLOATING_DISABLED; },
-      },
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*)',
-      },
-      _isBlameLoaded: Boolean,
-      _isBlameLoading: {
-        type: Boolean,
-        value: false,
-      },
-      _allPatchSets: {
-        type: Array,
-        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      _reviewedFiles: {
-        type: Object,
-        value: () => new Set(),
-      },
-    },
+        /**
+         * Object to contain the path of the next and previous file in the current
+         * change and patch range that has comments.
+         */
+        _commentSkips: {
+          type: Object,
+          computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+        },
+        _panelFloatingDisabled: {
+          type: Boolean,
+          value: () => window.PANEL_FLOATING_DISABLED,
+        },
+        _editMode: {
+          type: Boolean,
+          computed: '_computeEditMode(_patchRange.*)',
+        },
+        _isBlameLoaded: Boolean,
+        _isBlameLoading: {
+          type: Boolean,
+          value: false,
+        },
+        _allPatchSets: {
+          type: Array,
+          computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+        },
+        _revisionInfo: {
+          type: Object,
+          computed: '_getRevisionInfo(_change)',
+        },
+        _reviewedFiles: {
+          type: Object,
+          value: () => new Set(),
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
+    static get observers() {
+      return [
+        '_getProjectConfig(_change.project)',
+        '_getFiles(_changeNum, _patchRange.*)',
+        '_setReviewedObserver(_loggedIn, params.*, _prefs)',
+      ];
+    }
 
-    observers: [
-      '_getProjectConfig(_change.project)',
-      '_getFiles(_changeNum, _patchRange.*)',
-      '_setReviewedObserver(_loggedIn, params.*, _prefs)',
-    ],
-
-    keyBindings: {
-      esc: '_handleEscKey',
-    },
+    get keyBindings() {
+      return {
+        esc: '_handleEscKey',
+      };
+    }
 
     keyboardShortcuts() {
       return {
@@ -230,37 +243,41 @@
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
         [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
       };
-    },
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
       });
 
+      this.addEventListener('open-fix-preview',
+          this._onOpenFixPreview.bind(this));
       this.$.cursor.push('diffs', this.$.diffHost);
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _getProjectConfig(project) {
       return this.$.restAPI.getProjectConfig(project).then(
           config => {
             this._projectConfig = config;
           });
-    },
+    }
 
     _getChangeDetail(changeNum) {
       return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
         this._change = change;
         return change;
       });
-    },
+    }
 
     _getChangeEdit(changeNum) {
       return this.$.restAPI.getChangeEdit(this._changeNum);
-    },
+    }
 
     _getFiles(changeNum, patchRangeRecord) {
       // Polymer 2: check for undefined
@@ -274,25 +291,25 @@
           changeNum, patchRange).then(files => {
         this._fileList = files;
       });
-    },
+    }
 
     _getDiffPreferences() {
       return this.$.restAPI.getDiffPreferences().then(prefs => {
         this._prefs = prefs;
       });
-    },
+    }
 
     _getPreferences() {
       return this.$.restAPI.getPreferences();
-    },
+    }
 
     _getWindowWidth() {
       return window.innerWidth;
-    },
+    }
 
     _handleReviewedChange(e) {
       this._setReviewed(Polymer.dom(e).rootTarget.checked);
-    },
+    }
 
     _setReviewed(reviewed) {
       if (this._editMode) { return; }
@@ -301,12 +318,12 @@
         this.fire('show-alert', {message: ERR_REVIEW_STATUS});
         throw err;
       });
-    },
+    }
 
     _saveReviewedState(reviewed) {
       return this.$.restAPI.saveFileReviewed(this._changeNum,
           this._patchRange.patchNum, this._path, reviewed);
-    },
+    }
 
     _handleToggleFileReviewed(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -314,7 +331,7 @@
 
       e.preventDefault();
       this._setReviewed(!this.$.reviewed.checked);
-    },
+    }
 
     _handleEscKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -322,21 +339,21 @@
 
       e.preventDefault();
       this.$.diffHost.displayLine = false;
-    },
+    }
 
     _handleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveLeft();
-    },
+    }
 
     _handleRightPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveRight();
-    },
+    }
 
     _handlePrevLineOrFileWithComments(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -350,7 +367,11 @@
       e.preventDefault();
       this.$.diffHost.displayLine = true;
       this.$.cursor.moveUp();
-    },
+    }
+
+    _onOpenFixPreview(e) {
+      this.$.applyFixDialog.open(e);
+    }
 
     _handleNextLineOrFileWithComments(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -364,7 +385,7 @@
       e.preventDefault();
       this.$.diffHost.displayLine = true;
       this.$.cursor.moveDown();
-    },
+    }
 
     _moveToPreviousFileWithComment() {
       if (!this._commentSkips) { return; }
@@ -378,7 +399,7 @@
 
       Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
           this._patchRange.patchNum, this._patchRange.basePatchNum);
-    },
+    }
 
     _moveToNextFileWithComment() {
       if (!this._commentSkips) { return; }
@@ -391,19 +412,14 @@
 
       Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.next,
           this._patchRange.patchNum, this._patchRange.basePatchNum);
-    },
+    }
 
     _handleNewComment(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (this.$.diffHost.isRangeSelected()) { return; }
-      if (this.modifierPressed(e)) { return; }
-
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
       e.preventDefault();
-      const line = this.$.cursor.getTargetLineElement();
-      if (line) {
-        this.$.diffHost.addDraftAtLine(line);
-      }
-    },
+      this.$.cursor.createCommentInPlace();
+    }
 
     _handlePrevFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
@@ -412,7 +428,7 @@
 
       e.preventDefault();
       this._navToFile(this._path, this._fileList, -1);
-    },
+    }
 
     _handleNextFile(e) {
       // Check for meta key to avoid overriding native chrome shortcut.
@@ -421,7 +437,7 @@
 
       e.preventDefault();
       this._navToFile(this._path, this._fileList, 1);
-    },
+    }
 
     _handleNextChunkOrCommentThread(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -433,7 +449,7 @@
         if (this.modifierPressed(e)) { return; }
         this.$.cursor.moveToNextChunk();
       }
-    },
+    }
 
     _handlePrevChunkOrCommentThread(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -445,7 +461,7 @@
         if (this.modifierPressed(e)) { return; }
         this.$.cursor.moveToPreviousChunk();
       }
-    },
+    }
 
     _handleOpenReplyDialogOrToggleLeftPane(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -463,7 +479,7 @@
       this.set('changeViewState.showReplyDialog', true);
       e.preventDefault();
       this._navToChangeView();
-    },
+    }
 
     _handleUpToChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -471,7 +487,7 @@
 
       e.preventDefault();
       this._navToChangeView();
-    },
+    }
 
     _handleCommaKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -480,7 +496,7 @@
 
       e.preventDefault();
       this.$.diffPreferencesDialog.open();
-    },
+    }
 
     _handleToggleDiffMode(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
@@ -492,7 +508,7 @@
       } else {
         this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
       }
-    },
+    }
 
     _navToChangeView() {
       if (!this._changeNum || !this._patchRange.patchNum) { return; }
@@ -500,7 +516,7 @@
           this._change,
           this._patchRange,
           this._change && this._change.revisions);
-    },
+    }
 
     _navToFile(path, fileList, direction) {
       const newPath = this._getNavLinkPath(path, fileList, direction);
@@ -516,7 +532,7 @@
 
       Gerrit.Nav.navigateToDiff(this._change, newPath.path,
           this._patchRange.patchNum, this._patchRange.basePatchNum);
-    },
+    }
 
     /**
      * @param {?string} path The path of the current file being shown.
@@ -540,7 +556,7 @@
             this._change && this._change.revisions);
       }
       return this._getDiffUrl(this._change, this._patchRange, newPath.path);
-    },
+    }
 
     /**
      * Gives an object representing the target of navigating either left or
@@ -580,7 +596,7 @@
       }
 
       return {path: fileList[idx]};
-    },
+    }
 
     _getReviewedFiles(changeNum, patchNum) {
       return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
@@ -588,13 +604,13 @@
             this._reviewedFiles = new Set(files);
             return this._reviewedFiles;
           });
-    },
+    }
 
     _getReviewedStatus(editMode, changeNum, patchNum, path) {
       if (editMode) { return Promise.resolve(false); }
       return this._getReviewedFiles(changeNum, patchNum)
           .then(files => files.has(path));
-    },
+    }
 
     _paramsChanged(value) {
       if (value.view !== Gerrit.Nav.View.DIFF) { return; }
@@ -664,24 +680,26 @@
       promises.push(this._getChangeEdit(this._changeNum));
 
       this._loading = true;
-      return Promise.all(promises).then(r => {
-        const edit = r[4];
-        if (edit) {
-          this.set('_change.revisions.' + edit.commit.commit, {
-            _number: this.EDIT_NAME,
-            basePatchNum: edit.base_patch_set_number,
-            commit: edit.commit,
+      return Promise.all(promises)
+          .then(r => {
+            const edit = r[4];
+            if (edit) {
+              this.set('_change.revisions.' + edit.commit.commit, {
+                _number: this.EDIT_NAME,
+                basePatchNum: edit.base_patch_set_number,
+                commit: edit.commit,
+              });
+            }
+            this._loading = false;
+            this.$.diffHost.comments = this._commentsForDiff;
+            return this.$.diffHost.reload(true);
+          })
+          .then(() => {
+            this.$.reporting.diffViewFullyLoaded();
+            // If diff view displayed has not ended yet, it ends here.
+            this.$.reporting.diffViewDisplayed();
           });
-        }
-        this._loading = false;
-        this.$.diffHost.comments = this._commentsForDiff;
-        return this.$.diffHost.reload(true);
-      }).then(() => {
-        this.$.reporting.diffViewFullyLoaded();
-        // If diff view displayed has not ended yet, it ends here.
-        this.$.reporting.diffViewDisplayed();
-      });
-    },
+    }
 
     _changeViewStateChanged(changeViewState) {
       if (changeViewState.diffMode === null) {
@@ -690,7 +708,7 @@
           this.set('changeViewState.diffMode', prefs.default_diff_view);
         });
       }
-    },
+    }
 
     _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
       // Polymer 2: check for undefined
@@ -714,7 +732,7 @@
       if (params.view === Gerrit.Nav.View.DIFF) {
         this._setReviewed(true);
       }
-    },
+    }
 
     /**
      * If the params specify a diff address then configure the diff cursor.
@@ -727,14 +745,14 @@
         this.$.cursor.side = DiffSides.RIGHT;
       }
       this.$.cursor.initialLineNumber = params.lineNum;
-    },
+    }
 
     _getLineOfInterest(params) {
       // If there is a line number specified, pass it along to the diff so that
       // it will not get collapsed.
       if (!params.lineNum) { return null; }
       return {number: params.lineNum, leftSide: params.leftSide};
-    },
+    }
 
     _pathChanged(path) {
       if (path) {
@@ -746,7 +764,7 @@
 
       this.set('changeViewState.selectedFileIndex',
           this._fileList.indexOf(path));
-    },
+    }
 
     _getDiffUrl(change, patchRange, path) {
       if ([change, patchRange, path].some(arg => arg === undefined)) {
@@ -754,7 +772,7 @@
       }
       return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
           patchRange.basePatchNum);
-    },
+    }
 
     _patchRangeStr(patchRange) {
       let patchStr = patchRange.patchNum;
@@ -763,7 +781,7 @@
         patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
       }
       return patchStr;
-    },
+    }
 
     /**
      * When the latest patch of the change is selected (and there is no base
@@ -787,7 +805,7 @@
         basePatchNum = patchRange.basePatchNum;
       }
       return {patchNum, basePatchNum};
-    },
+    }
 
     _getChangePath(change, patchRange, revisions) {
       if ([change, patchRange].some(arg => arg === undefined)) {
@@ -796,16 +814,16 @@
       const range = this._getChangeUrlRange(patchRange, revisions);
       return Gerrit.Nav.getUrlForChange(change, range.patchNum,
           range.basePatchNum);
-    },
+    }
 
     _navigateToChange(change, patchRange, revisions) {
       const range = this._getChangeUrlRange(patchRange, revisions);
       Gerrit.Nav.navigateToChange(change, range.patchNum, range.basePatchNum);
-    },
+    }
 
     _computeChangePath(change, patchRangeRecord, revisions) {
       return this._getChangePath(change, patchRangeRecord.base, revisions);
-    },
+    }
 
     _formatFilesForDropdown(fileList, patchNum, changeComments) {
       // Polymer 2: check for undefined
@@ -829,7 +847,7 @@
         });
       }
       return dropdownContent;
-    },
+    }
 
     _computeCommentString(changeComments, patchNum, path) {
       const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
@@ -845,11 +863,11 @@
           (commentString && unresolvedString ? ', ' : '') +
           // Add parentheses around unresolved if it exists.
           (unresolvedString ? `${unresolvedString}` : '');
-    },
+    }
 
     _computePrefsButtonHidden(prefs, prefsDisabled) {
       return prefsDisabled || !prefs;
-    },
+    }
 
     _handleFileChange(e) {
       // This is when it gets set initially.
@@ -860,7 +878,7 @@
 
       Gerrit.Nav.navigateToDiff(this._change, path, this._patchRange.patchNum,
           this._patchRange.basePatchNum);
-    },
+    }
 
     _handleFileTap(e) {
       // async is needed so that that the click event is fired before the
@@ -868,7 +886,7 @@
       this.async(() => {
         this.$.dropdown.close();
       }, 1);
-    },
+    }
 
     _handlePatchChange(e) {
       const {basePatchNum, patchNum} = e.detail;
@@ -876,12 +894,12 @@
           this.patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
       Gerrit.Nav.navigateToDiff(
           this._change, this._path, patchNum, basePatchNum);
-    },
+    }
 
     _handlePrefsTap(e) {
       e.preventDefault();
       this.$.diffPreferencesDialog.open();
-    },
+    }
 
     /**
      * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
@@ -906,11 +924,11 @@
       } else {
         return 'SIDE_BY_SIDE';
       }
-    },
+    }
 
     _computeModeSelectHideClass(isImageDiff) {
       return isImageDiff ? 'hide' : '';
-    },
+    }
 
     _onLineSelected(e, detail) {
       this.$.cursor.moveToLineNumber(detail.number, detail.side);
@@ -922,7 +940,7 @@
           this._change.project, this._path, this._patchRange.patchNum,
           this._patchRange.basePatchNum, number, leftSide);
       history.replaceState(null, '', url);
-    },
+    }
 
     _computeDownloadDropdownLinks(
         project, changeNum, patchRange, path, diff) {
@@ -961,7 +979,7 @@
       }
 
       return links;
-    },
+    }
 
     _computeDownloadFileLink(
         project, changeNum, patchRange, path, isBase) {
@@ -981,13 +999,13 @@
       }
 
       return url;
-    },
+    }
 
     _computeDownloadPatchLink(project, changeNum, patchRange, path) {
       let url = this.changeBaseURL(project, changeNum, patchRange.patchNum);
       url += '/patch?zip&path=' + encodeURIComponent(path);
       return url;
-    },
+    }
 
     _loadComments() {
       return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
@@ -997,20 +1015,20 @@
         this._commentsForDiff = this._getCommentsForPath(this._path,
             this._patchRange, this._projectConfig);
       });
-    },
+    }
 
     _getPaths(patchRange) {
       return this._changeComments.getPaths(patchRange);
-    },
+    }
 
     _getCommentsForPath(path, patchRange, projectConfig) {
       return this._changeComments.getCommentsBySideForPath(path, patchRange,
           projectConfig);
-    },
+    }
 
     _getDiffDrafts() {
       return this.$.restAPI.getDiffDrafts(this._changeNum);
-    },
+    }
 
     _computeCommentSkips(commentMap, fileList, path) {
       // Polymer 2: check for undefined
@@ -1043,13 +1061,13 @@
       }
 
       return skips;
-    },
+    }
 
     _computeDiffClass(panelFloatingDisabled) {
       if (panelFloatingDisabled) {
         return 'noOverflow';
       }
-    },
+    }
 
     /**
      * @param {!Object} patchRangeRecord
@@ -1057,19 +1075,19 @@
     _computeEditMode(patchRangeRecord) {
       const patchRange = patchRangeRecord.base || {};
       return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
-    },
+    }
 
     /**
      * @param {boolean} editMode
      */
     _computeContainerClass(editMode) {
       return editMode ? 'editMode' : '';
-    },
+    }
 
     _computeBlameToggleLabel(loaded, loading) {
       if (loaded) { return 'Hide blame'; }
       return 'Show blame';
-    },
+    }
 
     /**
      * Load and display blame information if it has not already been loaded.
@@ -1091,15 +1109,15 @@
           .catch(() => {
             this._isBlameLoading = false;
           });
-    },
+    }
 
-    _computeBlameLoaderClass(isImageDiff) {
-      return !isImageDiff ? 'show' : '';
-    },
+    _computeBlameLoaderClass(isImageDiff, path) {
+      return !this.isMagicPath(path) && !isImageDiff ? 'show' : '';
+    }
 
     _getRevisionInfo(change) {
       return new Gerrit.RevisionInfo(change);
-    },
+    }
 
     _computeFileNum(file, files) {
       // Polymer 2: check for undefined
@@ -1108,7 +1126,7 @@
       }
 
       return files.findIndex(({value}) => value === file) + 1;
-    },
+    }
 
     /**
      * @param {number} fileNum
@@ -1120,16 +1138,16 @@
         return 'show';
       }
       return '';
-    },
+    }
 
     _handleExpandAllDiffContext(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       this.$.diffHost.expandAllContext();
-    },
+    }
 
     _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
       return disableDiffPrefs || !loggedIn;
-    },
+    }
 
     _handleNextUnreviewedFile(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -1140,10 +1158,12 @@
           .filter(file =>
             (file === this._path || !this._reviewedFiles.has(file)));
       this._navToFile(this._path, unreviewedFiles, 1);
-    },
+    }
 
     _handleReloadingDiffPreference() {
       this._getDiffPreferences();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffView.is, GrDiffView);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 573d75f..29cc950 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -281,8 +281,11 @@
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change, 'chell.go',
-          '10', '5'),
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          '10',
+          '5'),
       'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
@@ -342,8 +345,11 @@
       element._path = 'glados.txt';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(element._change, 'chell.go',
-          '1', PARENT), 'Should navigate to /c/42/1/chell.go');
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          '1',
+          PARENT), 'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
@@ -429,12 +435,14 @@
 
     suite('url params', () => {
       setup(() => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForDiff', (c, p, pn, bpn) => {
-          return `${c._number}-${p}-${pn}-${bpn}`;
-        });
-        sandbox.stub(Gerrit.Nav, 'getUrlForChange', (c, pn, bpn) => {
-          return `${c._number}-${pn}-${bpn}`;
-        });
+        sandbox.stub(
+            Gerrit.Nav,
+            'getUrlForDiff',
+            (c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
+        sandbox.stub(
+            Gerrit.Nav
+            , 'getUrlForChange',
+            (c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
       });
 
       test('_formattedFiles', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
index a7e391a..c62c603 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffLine) {
+(function(window) {
   'use strict';
 
   // Prevent redefinition.
@@ -23,6 +23,7 @@
   /**
    * A chunk of the diff that should be rendered together.
    *
+   * @constructor
    * @param {!GrDiffGroup.Type} type
    * @param {!Array<!GrDiffLine>=} opt_lines
    */
@@ -80,7 +81,6 @@
     DELTA: 'delta',
   };
 
-
   /**
    * Hides lines in the given range behind a context control group.
    *
@@ -286,4 +286,4 @@
   };
 
   window.GrDiffGroup = GrDiffGroup;
-})(window, GrDiffLine);
+})(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
index 16e8036..686410c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-group</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/web-component-tester/browser.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index a295293..42926ab 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -21,6 +21,7 @@
   if (window.GrDiffLine) { return; }
 
   /**
+   * @constructor
    * @param {GrDiffLine.Type} type
    * @param {number|string=} opt_beforeLine
    * @param {number|string=} opt_afterLine
@@ -37,7 +38,7 @@
     /** @type {boolean} */
     this.hasIntralineInfo = false;
 
-    /** @type {Array<GrDiffLine.Highlights>} */
+    /** @type {!Array<GrDiffLine.Highlights>} */
     this.highlights = [];
 
     /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
@@ -46,6 +47,7 @@
     this.text = '';
   }
 
+  /** @enum {string} */
   GrDiffLine.Type = {
     ADD: 'add',
     BOTH: 'both',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index aa69248..dea9789 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -37,11 +37,6 @@
       :host(.no-left) .sideBySide .right:not([data-value]) + td {
         display: none;
       }
-      ::slotted(*) .thread-group {
-        display: block;
-        max-width: var(--content-width, 80ch);
-        white-space: normal;
-      }
       :host {
         font-family: var(--monospace-font-family, ''), 'Roboto Mono';
         font-size: var(--font-size, var(--font-size-code, 12px));
@@ -52,6 +47,7 @@
         display: block;
         max-width: var(--content-width, 80ch);
         white-space: normal;
+        background-color: var(--diff-blank-background-color);
       }
       .diffContainer {
         display: flex;
@@ -67,13 +63,13 @@
         table-layout: fixed;
       }
       .lineNum {
-        background-color: var(--table-header-background-color);
+        background-color: var(--diff-blank-background-color);
       }
       .image-diff .gr-diff {
         text-align: center;
       }
       .image-diff img {
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        box-shadow: var(--elevation-level-1);
         max-width: 50em;
       }
       .image-diff .right.lineNum {
@@ -94,13 +90,16 @@
         color: var(--primary-text-color);
       }
       .content {
+        background-color: var(--diff-blank-background-color);
+      }
+      .contentText {
         background-color: var(--view-background-color);
       }
       .blank {
         background-color: var(--diff-blank-background-color);
       }
       .image-diff .content {
-        background-color: var(--table-header-background-color);
+        background-color: var(--diff-blank-background-color);
       }
       .full-width {
         width: 100%;
@@ -135,48 +134,48 @@
         min-width: var(--content-width, 80ch);
         width: var(--content-width, 80ch);
       }
-      .content.add .intraline,
+      .content.add .contentText .intraline,
       /* If there are no intraline info, consider everything changed */
-      .content.add.no-intraline-info,
-      .delta.total .content.add {
+      .content.add.no-intraline-info .contentText,
+      .delta.total .content.add .contentText {
         background-color: var(--dark-add-highlight-color);
       }
-      .content.add {
+      .content.add .contentText {
         background-color: var(--light-add-highlight-color);
       }
-      .content.remove .intraline,
+      .content.remove .contentText .intraline,
       /* If there are no intraline info, consider everything changed */
-      .content.remove.no-intraline-info,
-      .delta.total .content.remove {
+      .content.remove.no-intraline-info .contentText,
+      .delta.total .content.remove .contentText  {
         background-color: var(--dark-remove-highlight-color);
       }
-      .content.remove {
+      .content.remove .contentText {
         background-color: var(--light-remove-highlight-color);
       }
 
       /* dueToRebase */
-      .dueToRebase .content.add .intraline,
-      .delta.total.dueToRebase .content.add {
+      .dueToRebase .content.add .contentText .intraline,
+      .delta.total.dueToRebase .content.add .contentText {
         background-color: var(--dark-rebased-add-highlight-color);
       }
-      .dueToRebase .content.add {
+      .dueToRebase .content.add .contentText {
         background-color: var(--light-rebased-add-highlight-color);
       }
-      .dueToRebase .content.remove .intraline,
-      .delta.total.dueToRebase .content.remove {
+      .dueToRebase .content.remove .contentText .intraline,
+      .delta.total.dueToRebase .content.remove .contentText {
         background-color: var(--dark-rebased-remove-highlight-color);
       }
-      .dueToRebase .content.remove {
+      .dueToRebase .content.remove .contentText {
         background-color: var(--light-remove-add-highlight-color);
       }
 
       /* ignoredWhitespaceOnly */
-      .ignoredWhitespaceOnly .content.add .intraline,
-      .delta.total.ignoredWhitespaceOnly .content.add,
-      .ignoredWhitespaceOnly .content.add,
-      .ignoredWhitespaceOnly .content.remove .intraline,
-      .delta.total.ignoredWhitespaceOnly .content.remove,
-      .ignoredWhitespaceOnly .content.remove {
+      .ignoredWhitespaceOnly .content.add .contentText .intraline,
+      .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+      .ignoredWhitespaceOnly .content.add .contentText,
+      .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+      .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+      .ignoredWhitespaceOnly .content.remove .contentText {
         background: none;
       }
 
@@ -273,14 +272,15 @@
       }
       td.blame .sha {
         font-family: var(--monospace-font-family);
+        color: var(--link-color);
+        cursor: pointer;
       }
       .full-width td.blame {
         overflow: hidden;
         width: 200px;
       }
       /** Support the line length indicator **/
-      .full-width td.content,
-      .full-width td.blank {
+      .full-width td.content .contentText {
         /* Base 64 encoded 1x1px of #ddd */
         background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mO8+x8AAr8B3gzOjaQAAAAASUVORK5CYII=');
         background-position: var(--line-limit) 0;
@@ -294,13 +294,16 @@
         display: none;
       }
       .lineNum.COVERED {
-         background-color: #E0F2F1;
+         background-color: var(--coverage-covered, #e0f2f1);
       }
       .lineNum.NOT_COVERED {
-        background-color: #FFD1A4;
+        background-color: var(--coverage-not-covered, #ffd1a4);
       }
       .lineNum.PARTIALLY_COVERED {
-        background: linear-gradient(to right bottom, #FFD1A4 0%, #FFD1A4 50%, #E0F2F1 50%, #E0F2F1 100%);
+        background: linear-gradient(to right bottom, var(--coverage-not-covered, #ffd1a4) 0%,
+                                                     var(--coverage-not-covered, #ffd1a4) 50%,
+                                                     var(--coverage-covered, #e0f2f1) 50%,
+                                                     var(--coverage-covered, #e0f2f1) 100%);
       }
 
       /** BEGIN: Select and copy for Polymer 2 */
@@ -344,8 +347,12 @@
         text-align: center;
       }
     </style>
-    <style include="gr-syntax-theme"></style>
-    <style include="gr-ranged-comment-theme"></style>
+    <style include="gr-syntax-theme">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-ranged-comment-theme">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
       <template
           is="dom-repeat"
@@ -366,11 +373,10 @@
               coverage-ranges="[[coverageRanges]]"
               project-name="[[projectName]]"
               diff="[[diff]]"
-              diff-path="[[path]]"
+              path="[[path]]"
               change-num="[[changeNum]]"
               patch-num="[[patchRange.patchNum]]"
               view-mode="[[viewMode]]"
-              line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[baseImage]]"
               layers="[[layers]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 68ed91b..5d32e9b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -91,9 +91,18 @@
 
   const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
 
-  Polymer({
-    is: 'gr-diff',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @extends Polymer.Element
+   */
+  class GrDiff extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-diff'; }
     /**
      * Fired when the user selects a line.
      *
@@ -119,175 +128,190 @@
      * @event render
      */
 
-    properties: {
-      changeNum: String,
-      noAutoRender: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      patchRange: Object,
-      path: {
-        type: String,
-        observer: '_pathObserver',
-      },
-      prefs: {
-        type: Object,
-        observer: '_prefsObserver',
-      },
-      projectName: String,
-      displayLine: {
-        type: Boolean,
-        value: false,
-      },
-      isImageDiff: {
-        type: Boolean,
-      },
-      commitRange: Object,
-      hidden: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      noRenderOnPrefsChange: Boolean,
-      /** @type {!Array<!Gerrit.HoveredRange>} */
-      _commentRanges: {
-        type: Array,
-        value: () => [],
-      },
-      /** @type {!Array<!Gerrit.CoverageRange>} */
-      coverageRanges: {
-        type: Array,
-        value: () => [],
-      },
-      lineWrapping: {
-        type: Boolean,
-        value: false,
-        observer: '_lineWrappingObserver',
-      },
-      viewMode: {
-        type: String,
-        value: DiffViewMode.SIDE_BY_SIDE,
-        observer: '_viewModeObserver',
-      },
+    /**
+     * Fired for interaction reporting when a diff context is expanded.
+     * Contains an event.detail with numLines about the number of lines that
+     * were expanded.
+     *
+     * @event diff-context-expanded
+     */
 
-      /** @type {?Gerrit.LineOfInterest} */
-      lineOfInterest: Object,
+    static get properties() {
+      return {
+        changeNum: String,
+        noAutoRender: {
+          type: Boolean,
+          value: false,
+        },
+        /** @type {?} */
+        patchRange: Object,
+        path: {
+          type: String,
+          observer: '_pathObserver',
+        },
+        prefs: {
+          type: Object,
+          observer: '_prefsObserver',
+        },
+        projectName: String,
+        displayLine: {
+          type: Boolean,
+          value: false,
+        },
+        isImageDiff: {
+          type: Boolean,
+        },
+        commitRange: Object,
+        hidden: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+        noRenderOnPrefsChange: Boolean,
+        /** @type {!Array<!Gerrit.HoveredRange>} */
+        _commentRanges: {
+          type: Array,
+          value: () => [],
+        },
+        /** @type {!Array<!Gerrit.CoverageRange>} */
+        coverageRanges: {
+          type: Array,
+          value: () => [],
+        },
+        lineWrapping: {
+          type: Boolean,
+          value: false,
+          observer: '_lineWrappingObserver',
+        },
+        viewMode: {
+          type: String,
+          value: DiffViewMode.SIDE_BY_SIDE,
+          observer: '_viewModeObserver',
+        },
 
-      loading: {
-        type: Boolean,
-        value: false,
-        observer: '_loadingChanged',
-      },
+        /** @type {?Gerrit.LineOfInterest} */
+        lineOfInterest: Object,
 
-      loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      diff: {
-        type: Object,
-        observer: '_diffChanged',
-      },
-      _diffHeaderItems: {
-        type: Array,
-        value: [],
-        computed: '_computeDiffHeaderItems(diff.*)',
-      },
-      _diffTableClass: {
-        type: String,
-        value: '',
-      },
-      /** @type {?Object} */
-      baseImage: Object,
-      /** @type {?Object} */
-      revisionImage: Object,
+        loading: {
+          type: Boolean,
+          value: false,
+          observer: '_loadingChanged',
+        },
 
-      /**
-       * Whether the safety check for large diffs when whole-file is set has
-       * been bypassed. If the value is null, then the safety has not been
-       * bypassed. If the value is a number, then that number represents the
-       * context preference to use when rendering the bypassed diff.
-       *
-       * @type {number|null}
-       */
-      _safetyBypass: {
-        type: Number,
-        value: null,
-      },
+        loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+        diff: {
+          type: Object,
+          observer: '_diffChanged',
+        },
+        _diffHeaderItems: {
+          type: Array,
+          value: [],
+          computed: '_computeDiffHeaderItems(diff.*)',
+        },
+        _diffTableClass: {
+          type: String,
+          value: '',
+        },
+        /** @type {?Object} */
+        baseImage: Object,
+        /** @type {?Object} */
+        revisionImage: Object,
 
-      _showWarning: Boolean,
+        /**
+         * Whether the safety check for large diffs when whole-file is set has
+         * been bypassed. If the value is null, then the safety has not been
+         * bypassed. If the value is a number, then that number represents the
+         * context preference to use when rendering the bypassed diff.
+         *
+         * @type {number|null}
+         */
+        _safetyBypass: {
+          type: Number,
+          value: null,
+        },
 
-      /** @type {?string} */
-      errorMessage: {
-        type: String,
-        value: null,
-      },
+        _showWarning: Boolean,
 
-      /** @type {?Object} */
-      blame: {
-        type: Object,
-        value: null,
-        observer: '_blameChanged',
-      },
+        /** @type {?string} */
+        errorMessage: {
+          type: String,
+          value: null,
+        },
 
-      parentIndex: Number,
+        /** @type {?Object} */
+        blame: {
+          type: Object,
+          value: null,
+          observer: '_blameChanged',
+        },
 
-      _newlineWarning: {
-        type: String,
-        computed: '_computeNewlineWarning(diff)',
-      },
+        parentIndex: Number,
 
-      _diffLength: Number,
+        _newlineWarning: {
+          type: String,
+          computed: '_computeNewlineWarning(diff)',
+        },
 
-      /**
-       * Observes comment nodes added or removed after the initial render.
-       * Can be used to unregister when the entire diff is (re-)rendered or upon
-       * detachment.
-       *
-       * @type {?PolymerDomApi.ObserveHandle}
-       */
-      _incrementalNodeObserver: Object,
+        _diffLength: Number,
 
-      /**
-       * Observes comment nodes added or removed at any point.
-       * Can be used to unregister upon detachment.
-       *
-       * @type {?PolymerDomApi.ObserveHandle}
-       */
-      _nodeObserver: Object,
+        /**
+         * Observes comment nodes added or removed after the initial render.
+         * Can be used to unregister when the entire diff is (re-)rendered or upon
+         * detachment.
+         *
+         * @type {?PolymerDomApi.ObserveHandle}
+         */
+        _incrementalNodeObserver: Object,
 
-      /** Set by Polymer. */
-      isAttached: Boolean,
-      layers: Array,
-    },
+        /**
+         * Observes comment nodes added or removed at any point.
+         * Can be used to unregister upon detachment.
+         *
+         * @type {?PolymerDomApi.ObserveHandle}
+         */
+        _nodeObserver: Object,
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PatchSetBehavior,
-    ],
+        /** Set by Polymer. */
+        isAttached: Boolean,
+        layers: Array,
+      };
+    }
 
-    listeners: {
-      'create-range-comment': '_handleCreateRangeComment',
-      'render-content': '_handleRenderContent',
-    },
+    static get observers() {
+      return [
+        '_enableSelectionObserver(loggedIn, isAttached)',
+      ];
+    }
 
-    observers: [
-      '_enableSelectionObserver(loggedIn, isAttached)',
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('create-range-comment',
+          e => this._handleCreateRangeComment(e));
+      this.addEventListener('render-content',
+          () => this._handleRenderContent());
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._observeNodes();
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this._unobserveIncrementalNodes();
       this._unobserveNodes();
-    },
+    }
 
     showNoChangeMessage(loading, prefs, diffLength) {
       return !loading &&
-        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE'
-        && diffLength === 0;
-    },
+        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+        diffLength === 0;
+    }
 
     _enableSelectionObserver(loggedIn, isAttached) {
       // Polymer 2: check for undefined
@@ -302,7 +326,7 @@
         this.unlisten(document, 'selectionchange', '_handleSelectionChange');
         this.unlisten(document, 'mouseup', '_handleMouseUp');
       }
-    },
+    }
 
     _handleSelectionChange() {
       // Because of shadow DOM selections, we handle the selectionchange here,
@@ -310,7 +334,7 @@
       // corresponding range is determined and normalized.
       const selection = this._getShadowOrDocumentSelection();
       this.$.highlights.handleSelectionChange(selection, false);
-    },
+    }
 
     _handleMouseUp(e) {
       // To handle double-click outside of text creating comments, we check on
@@ -318,7 +342,7 @@
       // can't do that on selection change since the user may still be dragging.
       const selection = this._getShadowOrDocumentSelection();
       this.$.highlights.handleSelectionChange(selection, true);
-    },
+    }
 
     /** Gets the current selection, preferring the shadow DOM selection. */
     _getShadowOrDocumentSelection() {
@@ -329,7 +353,7 @@
       return this.root.getSelection ?
         this.root.getSelection() :
         document.getSelection();
-    },
+    }
 
     _observeNodes() {
       this._nodeObserver = Polymer.dom(this).observeNodes(info => {
@@ -338,7 +362,7 @@
         this._updateRanges(addedThreadEls, removedThreadEls);
         this._redispatchHoverEvents(addedThreadEls);
       });
-    },
+    }
 
     _updateRanges(addedThreadEls, removedThreadEls) {
       function commentRangeFromThreadEl(threadEl) {
@@ -354,17 +378,18 @@
           .map(commentRangeFromThreadEl)
           .filter(({range}) => range);
       for (const removedCommentRange of removedCommentRanges) {
-        const i = this._commentRanges.findIndex(commentRange => {
-          return commentRange.side === removedCommentRange.side &&
-              Gerrit.rangesEqual(commentRange.range, removedCommentRange.range);
-        });
+        const i = this._commentRanges
+            .findIndex(
+                cr => cr.side === removedCommentRange.side &&
+              Gerrit.rangesEqual(cr.range, removedCommentRange.range)
+            );
         this.splice('_commentRanges', i, 1);
       }
 
       if (addedCommentRanges && addedCommentRanges.length) {
         this.push('_commentRanges', ...addedCommentRanges);
       }
-    },
+    }
 
     /**
      * The key locations based on the comments and line of interests,
@@ -395,7 +420,7 @@
         }
       }
       return keyLocations;
-    },
+    }
 
     // Dispatch events that are handled by the gr-diff-highlight.
     _redispatchHoverEvents(addedThreadEls) {
@@ -409,13 +434,13 @@
               'comment-thread-mouseleave', {bubbles: true, composed: true}));
         });
       }
-    },
+    }
 
     /** Cancel any remaining diff builder rendering work. */
     cancel() {
       this.$.diffBuilder.cancel();
       this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
-    },
+    }
 
     /** @return {!Array<!HTMLElement>} */
     getCursorStops() {
@@ -425,16 +450,16 @@
 
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('.diff-row'));
-    },
+    }
 
     /** @return {boolean} */
     isRangeSelected() {
-      return this.$.highlights.isRangeSelected();
-    },
+      return !!this.$.highlights.selectedRange;
+    }
 
     toggleLeftDiff() {
       this.toggleClass('no-left');
-    },
+    }
 
     _blameChanged(newValue) {
       this.$.diffBuilder.setBlame(newValue);
@@ -443,7 +468,7 @@
       } else {
         this.classList.remove('showBlame');
       }
-    },
+    }
 
     /** @return {string} */
     _computeContainerClass(loggedIn, viewMode, displayLine) {
@@ -468,12 +493,15 @@
         classes.push('displayLine');
       }
       return classes.join(' ');
-    },
+    }
 
     _handleTap(e) {
       const el = Polymer.dom(e).localTarget;
 
       if (el.classList.contains('showContext')) {
+        this.fire('diff-context-expanded', {
+          numLines: e.detail.numLines,
+        });
         this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
       } else if (el.classList.contains('lineNum')) {
         this.addDraftAtLine(el);
@@ -483,7 +511,7 @@
         const target = this.$.diffBuilder.getLineElByChild(el);
         if (target) { this._selectLine(target); }
       }
-    },
+    }
 
     _selectLine(el) {
       this.fire('line-selected', {
@@ -491,7 +519,7 @@
         number: el.getAttribute('data-value'),
         path: this.path,
       });
-    },
+    }
 
     addDraftAtLine(el) {
       this._selectLine(el);
@@ -507,18 +535,29 @@
         }
       }
       this._createComment(el, lineNum);
-    },
+    }
+
+    createRangeComment() {
+      if (!this.isRangeSelected()) {
+        throw Error('Selection is needed for new range comment');
+      }
+      const {side, range} = this.$.highlights.selectedRange;
+      this._createCommentForSelection(side, range);
+    }
+
+    _createCommentForSelection(side, range) {
+      const lineNum = range.end_line;
+      const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+      if (this._isValidElForComment(lineEl)) {
+        this._createComment(lineEl, lineNum, side, range);
+      }
+    }
 
     _handleCreateRangeComment(e) {
       const range = e.detail.range;
       const side = e.detail.side;
-      const lineNum = range.end_line;
-      const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-
-      if (this._isValidElForComment(lineEl)) {
-        this._createComment(lineEl, lineNum, side, range);
-      }
-    },
+      this._createCommentForSelection(side, range);
+    }
 
     /** @return {boolean} */
     _isValidElForComment(el) {
@@ -542,7 +581,7 @@
         return false;
       }
       return true;
-    },
+    }
 
     /**
      * @param {!Object} lineEl
@@ -550,7 +589,7 @@
      * @param {string=} side
      * @param {!Object=} range
      */
-    _createComment(lineEl, lineNum=undefined, side=undefined, range=undefined) {
+    _createComment(lineEl, lineNum, side, range) {
       const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
       const contentEl = contentText.parentElement;
       side = side ||
@@ -570,11 +609,11 @@
           range,
         },
       }));
-    },
+    }
 
     _getThreadGroupForLine(contentEl) {
       return contentEl.querySelector('.thread-group');
-    },
+    }
 
     /**
      * Gets or creates a comment thread group for a specific line and side on a
@@ -594,7 +633,7 @@
         contentEl.appendChild(threadGroupEl);
       }
       return threadGroupEl;
-    },
+    }
 
     /**
      * The value to be used for the patch number of new comments created at the
@@ -620,7 +659,7 @@
         patchNum = this.patchRange.basePatchNum;
       }
       return patchNum;
-    },
+    }
 
     /** @return {boolean} */
     _getIsParentCommentByLineAndContent(lineEl, contentEl) {
@@ -631,7 +670,7 @@
         return true;
       }
       return false;
-    },
+    }
 
     /** @return {string} */
     _getCommentSideByLineAndContent(lineEl, contentEl) {
@@ -641,7 +680,7 @@
         side = 'left';
       }
       return side;
-    },
+    }
 
     _prefsObserver(newPrefs, oldPrefs) {
       // Scan the preference objects one level deep to see if they differ.
@@ -657,16 +696,16 @@
       if (differ) {
         this._prefsChanged(newPrefs);
       }
-    },
+    }
 
     _pathObserver() {
       // Call _prefsChanged(), because line-limit style value depends on path.
       this._prefsChanged(this.prefs);
-    },
+    }
 
     _viewModeObserver() {
       this._prefsChanged(this.prefs);
-    },
+    }
 
     /** @param {boolean} newValue */
     _loadingChanged(newValue) {
@@ -677,11 +716,11 @@
         this._showWarning = false;
         this.clearDiffContent();
       }
-    },
+    }
 
     _lineWrappingObserver() {
       this._prefsChanged(this.prefs);
-    },
+    }
 
     _prefsChanged(prefs) {
       if (!prefs) { return; }
@@ -712,14 +751,14 @@
       if (this.diff && !this.noRenderOnPrefsChange) {
         this._debounceRenderDiffTable();
       }
-    },
+    }
 
     _diffChanged(newValue) {
       if (newValue) {
         this._diffLength = this.getDiffLength(newValue);
         this._debounceRenderDiffTable();
       }
-    },
+    }
 
     /**
      * When called multiple times from the same microtask, will call
@@ -734,7 +773,7 @@
     _debounceRenderDiffTable() {
       this.debounce(
           RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
-    },
+    }
 
     _renderDiffTable() {
       this._unobserveIncrementalNodes();
@@ -764,7 +803,7 @@
                   detail: {contentRendered: true},
                 }));
           });
-    },
+    }
 
     _handleRenderContent() {
       this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
@@ -776,10 +815,9 @@
         // for each line from the start.
         let lastEl;
         for (const threadEl of addedThreadEls) {
-          const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
           const commentSide = threadEl.getAttribute('comment-side');
-          const lineEl = this.$.diffBuilder.getLineElByNumber(
-              lineNumString, commentSide);
+          const lineEl = this._getLineElement(threadEl,
+              commentSide);
           const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
           const contentEl = contentText.parentElement;
           const threadGroupEl = this._getOrCreateThreadGroup(
@@ -803,19 +841,31 @@
           lastEl.replaceWith(lastEl);
         }
       });
-    },
+    }
+
+    _getLineElement(threadEl, commentSide) {
+      const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+      const lineEl = this.$.diffBuilder.getLineElByNumber(
+          lineNumString, commentSide);
+      if (lineEl) {
+        return lineEl;
+      }
+      // It is possible to add comment to non-existing line via API
+      threadEl.invalidLineNumber = true;
+      return this.$.diffBuilder.getLineElByNumber('FILE', commentSide);
+    }
 
     _unobserveIncrementalNodes() {
       if (this._incrementalNodeObserver) {
         Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
       }
-    },
+    }
 
     _unobserveNodes() {
       if (this._nodeObserver) {
         Polymer.dom(this).unobserveNodes(this._nodeObserver);
       }
-    },
+    }
 
     /**
      * Get the preferences object including the safety bypass context (if any).
@@ -825,45 +875,44 @@
         return Object.assign({}, this.prefs, {context: this._safetyBypass});
       }
       return this.prefs;
-    },
+    }
 
     clearDiffContent() {
       this._unobserveIncrementalNodes();
       this.$.diffTable.innerHTML = null;
-    },
+    }
 
     /** @return {!Array} */
     _computeDiffHeaderItems(diffInfoRecord) {
       const diffInfo = diffInfoRecord.base;
       if (!diffInfo || !diffInfo.diff_header) { return []; }
-      return diffInfo.diff_header.filter(item => {
-        return !(item.startsWith('diff --git ') ||
+      return diffInfo.diff_header
+          .filter(item => !(item.startsWith('diff --git ') ||
             item.startsWith('index ') ||
             item.startsWith('+++ ') ||
             item.startsWith('--- ') ||
-            item === 'Binary files differ');
-      });
-    },
+            item === 'Binary files differ'));
+    }
 
     /** @return {boolean} */
     _computeDiffHeaderHidden(items) {
       return items.length === 0;
-    },
+    }
 
     _handleFullBypass() {
       this._safetyBypass = FULL_CONTEXT;
       this._debounceRenderDiffTable();
-    },
+    }
 
     _handleLimitedBypass() {
       this._safetyBypass = LIMITED_CONTEXT;
       this._debounceRenderDiffTable();
-    },
+    }
 
     /** @return {string} */
     _computeWarningClass(showWarning) {
       return showWarning ? 'warn' : '';
-    },
+    }
 
     /**
      * @param {string} errorMessage
@@ -871,11 +920,11 @@
      */
     _computeErrorClass(errorMessage) {
       return errorMessage ? 'showError' : '';
-    },
+    }
 
     expandAllContext() {
       this._handleFullBypass();
-    },
+    }
 
     /**
      * Find the last chunk for the given side.
@@ -912,7 +961,7 @@
       if (chunkIndex === -1) { return null; }
 
       return chunk;
-    },
+    }
 
     /**
      * Check whether the specified side of the diff has a trailing newline.
@@ -934,7 +983,7 @@
         lines = leftSide ? chunk.a : chunk.b;
       }
       return lines[lines.length - 1] === '';
-    },
+    }
 
     /**
      * @param {!Object} diff
@@ -952,7 +1001,7 @@
       }
       if (!messages.length) { return null; }
       return messages.join(' — ');
-    },
+    }
 
     /**
      * @param {string} warning
@@ -962,7 +1011,7 @@
     _computeNewlineWarningClass(warning, loading) {
       if (loading || !warning) { return 'newlineWarning hidden'; }
       return 'newlineWarning';
-    },
+    }
 
     /**
      * Get the approximate length of the diff as the sum of the maximum
@@ -982,6 +1031,8 @@
               sec.hasOwnProperty('b') ? sec.b.length : 0);
         }
       }, 0);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiff.is, GrDiff);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index ec2f5f1..2805eb4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -76,7 +76,6 @@
       });
     });
 
-
     test('cancel', () => {
       element = fixture('basic');
       const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
@@ -868,7 +867,6 @@
           assert.isNull(element._lastChunkForSide(diff, true));
         });
 
-
         test('deletion with b undefined', () => {
           const diff = {content: [
             {a: ['foo', 'bar', 'baz']},
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index c7bf4ec3..d24e0bc 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -21,49 +21,58 @@
   const PATCH_DESC_MAX_LENGTH = 500;
 
   /**
+   * @appliesMixin Gerrit.PatchSetMixin
+   */
+  /**
    * Fired when the patch range changes
    *
    * @event patch-range-change
    *
    * @property {string} patchNum
    * @property {string} basePatchNum
+   * @extends Polymer.Element
    */
+  class GrPatchRangeSelect extends Polymer.mixinBehaviors( [
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-patch-range-select'; }
 
-  Polymer({
-    is: 'gr-patch-range-select',
-
-    properties: {
-      availablePatches: Array,
-      _baseDropdownContent: {
-        type: Object,
-        computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
+    static get properties() {
+      return {
+        availablePatches: Array,
+        _baseDropdownContent: {
+          type: Object,
+          computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
             '_sortedRevisions, changeComments, revisionInfo)',
-      },
-      _patchDropdownContent: {
-        type: Object,
-        computed: '_computePatchDropdownContent(availablePatches,' +
+        },
+        _patchDropdownContent: {
+          type: Object,
+          computed: '_computePatchDropdownContent(availablePatches,' +
             'basePatchNum, _sortedRevisions, changeComments)',
-      },
-      changeNum: String,
-      changeComments: Object,
-      /** @type {{ meta_a: !Array, meta_b: !Array}} */
-      filesWeblinks: Object,
-      patchNum: String,
-      basePatchNum: String,
-      revisions: Object,
-      revisionInfo: Object,
-      _sortedRevisions: Array,
-    },
+        },
+        changeNum: String,
+        changeComments: Object,
+        /** @type {{ meta_a: !Array, meta_b: !Array}} */
+        filesWeblinks: Object,
+        patchNum: String,
+        basePatchNum: String,
+        revisions: Object,
+        revisionInfo: Object,
+        _sortedRevisions: Array,
+      };
+    }
 
-    observers: [
-      '_updateSortedRevisions(revisions.*)',
-    ],
-
-    behaviors: [Gerrit.PatchSetBehavior],
+    static get observers() {
+      return [
+        '_updateSortedRevisions(revisions.*)',
+      ];
+    }
 
     _getShaForPatch(patch) {
       return patch.sha.substring(0, 10);
-    },
+    }
 
     _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
         changeComments, revisionInfo) {
@@ -111,13 +120,13 @@
       }
 
       return dropdownContent;
-    },
+    }
 
     _computeMobileText(patchNum, changeComments, revisions) {
       return `${patchNum}` +
           `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
           `${this._computePatchSetDescription(revisions, patchNum, true)}`;
-    },
+    }
 
     _computePatchDropdownContent(availablePatches, basePatchNum,
         _sortedRevisions, changeComments) {
@@ -143,13 +152,13 @@
         }));
       }
       return dropdownContent;
-    },
+    }
 
     _computeText(patchNum, prefix, changeComments, sha) {
       return `${prefix}${patchNum}` +
-        `${this._computePatchSetCommentsString(changeComments, patchNum)}`
-          + (` | ${sha}`);
-    },
+        `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+          (` | ${sha}`);
+    }
 
     _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments,
         sha) {
@@ -167,12 +176,12 @@
         entry['date'] = date;
       }
       return entry;
-    },
+    }
 
     _updateSortedRevisions(revisionsRecord) {
       const revisions = revisionsRecord.base;
       this._sortedRevisions = this.sortRevisions(Object.values(revisions));
-    },
+    }
 
     /**
      * The basePatchNum should always be <= patchNum -- because sortedRevisions
@@ -186,7 +195,7 @@
     _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
       return this.findSortedIndex(basePatchNum, sortedRevisions) <=
           this.findSortedIndex(patchNum, sortedRevisions);
-    },
+    }
 
     /**
      * The basePatchNum should always be <= patchNum -- because sortedRevisions
@@ -215,8 +224,7 @@
 
       return this.findSortedIndex(basePatchNum, sortedRevisions) <=
           this.findSortedIndex(patchNum, sortedRevisions);
-    },
-
+    }
 
     _computePatchSetCommentsString(changeComments, patchNum) {
       if (!changeComments) { return; }
@@ -237,7 +245,7 @@
           // Add a comma + space if both comments and unresolved
           (commentString && unresolvedString ? ', ' : '') +
           `${unresolvedString})`;
-    },
+    }
 
     /**
      * @param {!Array} revisions
@@ -249,7 +257,7 @@
       return (rev && rev.description) ?
         (opt_addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    },
+    }
 
     /**
      * @param {!Array} revisions
@@ -258,7 +266,7 @@
     _computePatchSetDate(revisions, patchNum) {
       const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return rev ? rev.created : undefined;
-    },
+    }
 
     /**
      * Catches value-change events from the patchset dropdowns and determines
@@ -276,6 +284,8 @@
 
       this.dispatchEvent(
           new CustomEvent('patch-range-change', {detail, bubbles: false}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPatchRangeSelect.is, GrPatchRangeSelect);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index ee893ee..4cb618f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-patch-range-select</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -235,10 +235,11 @@
           // Should be recomputed for each available patch
           sandbox.stub(element, '_computeBaseDropdownContent');
           assert.equal(element._computeBaseDropdownContent.callCount, 0);
-          commentApiWrapper.loadComments().then().then(() => {
-            assert.equal(element._computeBaseDropdownContent.callCount, 1);
-            done();
-          });
+          commentApiWrapper.loadComments().then()
+              .then(() => {
+                assert.equal(element._computeBaseDropdownContent.callCount, 1);
+                done();
+              });
         });
 
     test('_computePatchDropdownContent called when basePatchNum updates', () => {
@@ -286,9 +287,10 @@
       // Should be recomputed for each available patch
       sandbox.stub(element, '_computePatchDropdownContent');
       assert.equal(element._computePatchDropdownContent.callCount, 0);
-      commentApiWrapper.loadComments().then().then(() => {
-        done();
-      });
+      commentApiWrapper.loadComments().then()
+          .then(() => {
+            done();
+          });
     });
 
     test('_computePatchDropdownContent', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 5a03579..fd94b61 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -23,9 +23,11 @@
   const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
   const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
 
-  Polymer({
-    is: 'gr-ranged-comment-layer',
-
+  /** @extends Polymer.Element */
+  class GrRangedCommentLayer extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-ranged-comment-layer'; }
     /**
      * Fired when the range in a range comment was malformed and had to be
      * normalized.
@@ -35,26 +37,30 @@
      * @event normalize-range
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {!Array<!Gerrit.HoveredRange>} */
-      commentRanges: Array,
-      _listeners: {
-        type: Array,
-        value() { return []; },
-      },
-      _rangesMap: {
-        type: Object,
-        value() { return {left: {}, right: {}}; },
-      },
-    },
+        commentRanges: Array,
+        _listeners: {
+          type: Array,
+          value() { return []; },
+        },
+        _rangesMap: {
+          type: Object,
+          value() { return {left: {}, right: {}}; },
+        },
+      };
+    }
 
-    observers: [
-      '_handleCommentRangesChange(commentRanges.*)',
-    ],
+    static get observers() {
+      return [
+        '_handleCommentRangesChange(commentRanges.*)',
+      ];
+    }
 
     get styleModuleName() {
       return 'gr-ranged-comment-styles';
-    },
+    }
 
     /**
      * Layer method to add annotations to a line.
@@ -82,7 +88,7 @@
             range.end - range.start,
             range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
       }
-    },
+    }
 
     /**
      * Register a listener for layer updates.
@@ -93,7 +99,7 @@
      */
     addListener(fn) {
       this._listeners.push(fn);
-    },
+    }
 
     /**
      * Notify Layer listeners of changes to annotations.
@@ -106,7 +112,7 @@
       for (const listener of this._listeners) {
         listener(start, end, side);
       }
-    },
+    }
 
     /**
      * Handle change in the ranges by updating the ranges maps and by
@@ -165,7 +171,7 @@
           }
         }
       }
-    },
+    }
 
     _updateRangesMap(side, range, hovering, operation) {
       const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
@@ -176,7 +182,7 @@
         operation(forLine, start, end, hovering);
       }
       this._notifyUpdateRange(range.start_line, range.end_line, side);
-    },
+    }
 
     _getRangesForLine(line, side) {
       const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
@@ -203,7 +209,9 @@
             return range;
           })
           // Sort the ranges so that hovering highlights are on top.
-          .sort((a, b) => a.hovering && !b.hovering ? 1 : 0);
-    },
-  });
+          .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0));
+    }
+  }
+
+  customElements.define(GrRangedCommentLayer.is, GrRangedCommentLayer);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 9d207a5..4ed744b 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ranged-comment-layer</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index b1b3e0f..3d831c9 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -17,48 +17,40 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-selection-action-box',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrSelectionActionBox extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-selection-action-box'; }
     /**
-     * Fired when the comment creation action was taken (hotkey, click).
+     * Fired when the comment creation action was taken (click).
      *
-     * @event create-range-comment
+     * @event create-comment-requested
      */
 
-    properties: {
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      range: {
-        type: Object,
-        value: {
-          start_line: NaN,
-          start_character: NaN,
-          end_line: NaN,
-          end_character: NaN,
+    static get properties() {
+      return {
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
         },
-      },
-      positionBelow: Boolean,
-      side: {
-        type: String,
-        value: '',
-      },
-    },
+        positionBelow: Boolean,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+    /** @override */
+    created() {
+      super.created();
 
-    listeners: {
-      mousedown: '_handleMouseDown', // See https://crbug.com/gerrit/4767
-    },
-
-    keyBindings: {
-      c: '_handleCKey',
-    },
+      // See https://crbug.com/gerrit/4767
+      this.addEventListener('mousedown',
+          e => this._handleMouseDown(e));
+    }
 
     placeAbove(el) {
       Polymer.dom.flush();
@@ -69,7 +61,7 @@
           rect.top - parentRect.top - boxRect.height - 6 + 'px';
       this.style.left =
           rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-    },
+    }
 
     placeBelow(el) {
       Polymer.dom.flush();
@@ -80,14 +72,14 @@
       rect.top - parentRect.top + boxRect.height - 6 + 'px';
       this.style.left =
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-    },
+    }
 
     _getParentBoundingClientRect() {
       // With native shadow DOM, the parent is the shadow root, not the gr-diff
       // element
       const parent = this.parentElement || this.parentNode.host;
       return parent.getBoundingClientRect();
-    },
+    }
 
     _getTargetBoundingRect(el) {
       let rect;
@@ -100,25 +92,15 @@
         rect = el.getBoundingClientRect();
       }
       return rect;
-    },
-
-    _handleCKey(e) {
-      if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
-
-      e.preventDefault();
-      this._fireCreateComment();
-    },
+    }
 
     _handleMouseDown(e) {
       if (e.button !== 0) { return; } // 0 = main button
       e.preventDefault();
       e.stopPropagation();
-      this._fireCreateComment();
-    },
+      this.fire('create-comment-requested');
+    }
+  }
 
-    _fireCreateComment() {
-      this.fire('create-range-comment', {side: this.side, range: this.range});
-    },
-  });
+  customElements.define(GrSelectionActionBox.is, GrSelectionActionBox);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index b950e7b..57adf32 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-selection-action-box</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -59,11 +59,6 @@
       assert.isFalse(element.fire.called);
     });
 
-    test('reacts to hotkey', () => {
-      MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
-      assert.isTrue(element.fire.called);
-    });
-
     suite('mousedown reacts only to main button', () => {
       let e;
 
@@ -73,40 +68,22 @@
           preventDefault: sandbox.stub(),
           stopPropagation: sandbox.stub(),
         };
-        sandbox.stub(element, '_fireCreateComment');
       });
 
       test('event handled if main button', () => {
         element._handleMouseDown(e);
         assert.isTrue(e.preventDefault.called);
+        assert(element.fire.calledWithExactly('create-comment-requested'));
       });
 
       test('event ignored if not main button', () => {
         e.button = 1;
         element._handleMouseDown(e);
         assert.isFalse(e.preventDefault.called);
+        assert.isFalse(element.fire.called);
       });
     });
 
-    test('event fired contains playload', () => {
-      const side = 'left';
-      const range = {
-        start_line: 1,
-        start_character: 11,
-        end_line: 2,
-        end_character: 42,
-      };
-      element.side = 'left';
-      element.range = range;
-      MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
-      assert(element.fire.calledWithExactly(
-          'create-range-comment',
-          {
-            side,
-            range,
-          }));
-    });
-
     suite('placeAbove', () => {
       let target;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index b9aa082..34bda26 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -131,55 +131,60 @@
   const GO_BACKSLASH_LITERAL = '\'\\\\\'';
   const GLOBAL_LT_PATTERN = /</g;
 
-  Polymer({
-    is: 'gr-syntax-layer',
+  /** @extends Polymer.Element */
+  class GrSyntaxLayer extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-syntax-layer'; }
 
-    properties: {
-      diff: {
-        type: Object,
-        observer: '_diffChanged',
-      },
-      enabled: {
-        type: Boolean,
-        value: true,
-      },
-      _baseRanges: {
-        type: Array,
-        value() { return []; },
-      },
-      _revisionRanges: {
-        type: Array,
-        value() { return []; },
-      },
-      _baseLanguage: String,
-      _revisionLanguage: String,
-      _listeners: {
-        type: Array,
-        value() { return []; },
-      },
-      /** @type {?number} */
-      _processHandle: Number,
-      /**
-       * The promise last returned from `process()` while the asynchronous
-       * processing is running - `null` otherwise. Provides a `cancel()`
-       * method that rejects it with `{isCancelled: true}`.
-       *
-       * @type {?Object}
-       */
-      _processPromise: {
-        type: Object,
-        value: null,
-      },
-      _hljs: Object,
-    },
+    static get properties() {
+      return {
+        diff: {
+          type: Object,
+          observer: '_diffChanged',
+        },
+        enabled: {
+          type: Boolean,
+          value: true,
+        },
+        _baseRanges: {
+          type: Array,
+          value() { return []; },
+        },
+        _revisionRanges: {
+          type: Array,
+          value() { return []; },
+        },
+        _baseLanguage: String,
+        _revisionLanguage: String,
+        _listeners: {
+          type: Array,
+          value() { return []; },
+        },
+        /** @type {?number} */
+        _processHandle: Number,
+        /**
+         * The promise last returned from `process()` while the asynchronous
+         * processing is running - `null` otherwise. Provides a `cancel()`
+         * method that rejects it with `{isCancelled: true}`.
+         *
+         * @type {?Object}
+         */
+        _processPromise: {
+          type: Object,
+          value: null,
+        },
+        _hljs: Object,
+      };
+    }
 
     addListener(fn) {
       this.push('_listeners', fn);
-    },
+    }
 
     removeListener(fn) {
       this._listeners = this._listeners.filter(f => f != fn);
-    },
+    }
 
     /**
      * Annotation layer method to add syntax annotations to the given element
@@ -217,14 +222,14 @@
         GrAnnotation.annotateElement(
             el, range.start, range.length, range.className);
       }
-    },
+    }
 
     _getLanguage(diffFileMetaInfo) {
       // The Gerrit API provides only content-type, but for other users of
       // gr-diff it may be more convenient to specify the language directly.
       return diffFileMetaInfo.language ||
           LANGUAGE_MAP[diffFileMetaInfo.content_type];
-    },
+    }
 
     /**
      * Start processing syntax for the loaded diff and notify layer listeners
@@ -264,43 +269,43 @@
         lastNotify: {left: 1, right: 1},
       };
 
+      const rangesCache = new Map();
+
       this._processPromise = util.makeCancelable(this._loadHLJS()
-          .then(() => {
-            return new Promise(resolve => {
-              const nextStep = () => {
-                this._processHandle = null;
-                this._processNextLine(state);
+          .then(() => new Promise(resolve => {
+            const nextStep = () => {
+              this._processHandle = null;
+              this._processNextLine(state, rangesCache);
 
-                // Move to the next line in the section.
-                state.lineIndex++;
+              // Move to the next line in the section.
+              state.lineIndex++;
 
-                // If the section has been exhausted, move to the next one.
-                if (this._isSectionDone(state)) {
-                  state.lineIndex = 0;
-                  state.sectionIndex++;
-                }
+              // If the section has been exhausted, move to the next one.
+              if (this._isSectionDone(state)) {
+                state.lineIndex = 0;
+                state.sectionIndex++;
+              }
 
-                // If all sections have been exhausted, finish.
-                if (state.sectionIndex >= this.diff.content.length) {
-                  resolve();
-                  this._notify(state);
-                  return;
-                }
+              // If all sections have been exhausted, finish.
+              if (state.sectionIndex >= this.diff.content.length) {
+                resolve();
+                this._notify(state);
+                return;
+              }
 
-                if (state.lineIndex % 100 === 0) {
-                  this._notify(state);
-                  this._processHandle = this.async(nextStep, ASYNC_DELAY);
-                } else {
-                  nextStep.call(this);
-                }
-              };
+              if (state.lineIndex % 100 === 0) {
+                this._notify(state);
+                this._processHandle = this.async(nextStep, ASYNC_DELAY);
+              } else {
+                nextStep.call(this);
+              }
+            };
 
-              this._processHandle = this.async(nextStep, 1);
-            });
-          }));
+            this._processHandle = this.async(nextStep, 1);
+          })));
       return this._processPromise
           .finally(() => { this._processPromise = null; });
-    },
+    }
 
     /**
      * Cancel any asynchronous syntax processing jobs.
@@ -313,13 +318,13 @@
       if (this._processPromise) {
         this._processPromise.cancel();
       }
-    },
+    }
 
     _diffChanged() {
       this._cancel();
       this._baseRanges = [];
       this._revisionRanges = [];
-    },
+    }
 
     /**
      * Take a string of HTML with the (potentially nested) syntax markers
@@ -327,13 +332,22 @@
      * markers.
      *
      * @param {string} str The string of HTML.
+     * @param {Map<string, !Array<!Object>>} rangesCache A map for caching
+     * ranges for each string. A cache is read and written by this method.
+     * Since diff is mostly comparing same file on two sides, there is good rate
+     * of duplication at least for parts that are on left and right parts.
      * @return {!Array<!Object>} The list of ranges.
      */
-    _rangesFromString(str) {
+    _rangesFromString(str, rangesCache) {
+      const cached = rangesCache.get(str);
+      if (cached) return cached;
+
       const div = document.createElement('div');
       div.innerHTML = str;
-      return this._rangesFromElement(div, 0);
-    },
+      const ranges = this._rangesFromElement(div, 0);
+      rangesCache.set(str, ranges);
+      return ranges;
+    }
 
     _rangesFromElement(elem, offset) {
       let result = [];
@@ -356,7 +370,7 @@
         offset += nodeLength;
       }
       return result;
-    },
+    }
 
     /**
      * For a given state, process the syntax for the next line (or pair of
@@ -364,7 +378,7 @@
      *
      * @param {!Object} state The processing state for the layer.
      */
-    _processNextLine(state) {
+    _processNextLine(state, rangesCache) {
       let baseLine;
       let revisionLine;
 
@@ -393,7 +407,8 @@
         baseLine = this._workaround(this._baseLanguage, baseLine);
         result = this._hljs.highlight(this._baseLanguage, baseLine, true,
             state.baseContext);
-        this.push('_baseRanges', this._rangesFromString(result.value));
+        this.push('_baseRanges',
+            this._rangesFromString(result.value, rangesCache));
         state.baseContext = result.top;
       }
 
@@ -402,10 +417,11 @@
         revisionLine = this._workaround(this._revisionLanguage, revisionLine);
         result = this._hljs.highlight(this._revisionLanguage, revisionLine,
             true, state.revisionContext);
-        this.push('_revisionRanges', this._rangesFromString(result.value));
+        this.push('_revisionRanges',
+            this._rangesFromString(result.value, rangesCache));
         state.revisionContext = result.top;
       }
-    },
+    }
 
     /**
      * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
@@ -472,7 +488,7 @@
       }
 
       return line;
-    },
+    }
 
     /**
      * Tells whether the state has exhausted its current section.
@@ -488,7 +504,7 @@
         return (!section.a || state.lineIndex >= section.a.length) &&
             (!section.b || state.lineIndex >= section.b.length);
       }
-    },
+    }
 
     /**
      * For a given state, notify layer listeners of any processed line ranges
@@ -511,18 +527,20 @@
             'right');
         state.lastNotify.right = state.lineNums.right;
       }
-    },
+    }
 
     _notifyRange(start, end, side) {
       for (const fn of this._listeners) {
         fn(start, end, side);
       }
-    },
+    }
 
     _loadHLJS() {
       return this.$.libLoader.getHLJS().then(hljs => {
         this._hljs = hljs;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrSyntaxLayer.is, GrSyntaxLayer);
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index 472db21..88fd3b9 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-syntax-layer</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -194,7 +194,7 @@
       const mockHLJS = getMockHLJS();
       const highlightSpy = sinon.spy(mockHLJS, 'highlight');
       sandbox.stub(element.$.libLoader, 'getHLJS',
-          () => { return Promise.resolve(mockHLJS); });
+          () => Promise.resolve(mockHLJS));
       const processNextSpy = sandbox.spy(element, '_processNextLine');
       const processPromise = element.process();
 
@@ -388,10 +388,20 @@
         '<span class="non-whtelisted-class">',
         '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
         '</span>'].join('');
-      const result = element._rangesFromString(str);
+      const result = element._rangesFromString(str, new Map());
       assert.notEqual(result.length, 0);
     });
 
+    test('_rangesFromString cache same syntax markers', () => {
+      sandbox.spy(element, '_rangesFromElement');
+      const str =
+        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
+      const cacheMap = new Map();
+      element._rangesFromString(str, cacheMap);
+      element._rangesFromString(str, cacheMap);
+      assert.isTrue(element._rangesFromElement.calledOnce);
+    });
+
     test('_isSectionDone', () => {
       let state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
index 5072b9d..5ae679e 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
@@ -25,8 +25,12 @@
 
 <dom-module id="gr-documentation-search">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-table-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-table-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <gr-list-view
         filter="[[_filter]]"
         items=false
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
index f850b9d..022a985 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -17,50 +17,58 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-documentation-search',
+  /**
+   * @appliesMixin Gerrit.ListViewMixin
+   * @extends Polymer.Element
+   */
+  class GrDocumentationSearch extends Polymer.mixinBehaviors( [
+    Gerrit.ListViewBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-documentation-search'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
 
-      _path: {
-        type: String,
-        readOnly: true,
-        value: '/Documentation',
-      },
-      _documentationSearches: Array,
+        _path: {
+          type: String,
+          readOnly: true,
+          value: '/Documentation',
+        },
+        _documentationSearches: Array,
 
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: {
-        type: String,
-        value: '',
-      },
-    },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _filter: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.ListViewBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.dispatchEvent(
           new CustomEvent('title-change', {title: 'Documentation Search'}));
-    },
+    }
 
     _paramsChanged(params) {
       this._loading = true;
       this._filter = this.getFilterValue(params);
 
       return this._getDocumentationSearches(this._filter);
-    },
+    }
 
     _getDocumentationSearches(filter) {
       this._documentationSearches = [];
@@ -71,11 +79,13 @@
             this._documentationSearches = searches;
             this._loading = false;
           });
-    },
+    }
 
     _computeSearchUrl(url) {
       if (!url) { return ''; }
       return this.getBaseUrl() + '/' + url;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDocumentationSearch.is, GrDocumentationSearch);
 })();
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
index 84298e2..d604b1f 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-documentation-search</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
@@ -90,9 +90,10 @@
       });
 
       test('_paramsChanged', done => {
-        sandbox.stub(element.$.restAPI, 'getDocumentationSearches', () => {
-          return Promise.resolve(documentationSearches);
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'getDocumentationSearches',
+            () => Promise.resolve(documentationSearches));
         const value = {
           filter: 'test',
         };
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
index ed96bb2..73dbaf8 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -17,23 +17,29 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-default-editor',
-
+  /** @extends Polymer.Element */
+  class GrDefaultEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-default-editor'; }
     /**
      * Fired when the content of the editor changes.
      *
      * @event content-change
      */
 
-    properties: {
-      fileContent: String,
-    },
+    static get properties() {
+      return {
+        fileContent: String,
+      };
+    }
 
     _handleTextareaInput(e) {
       this.dispatchEvent(new CustomEvent(
           'content-change',
           {detail: {value: e.target.value}, bubbles: true, composed: true}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDefaultEditor.is, GrDefaultEditor);
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
index c986e7c..4635b1c 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
@@ -17,7 +17,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-default-editor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.html b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
index d526ccd..5895124 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
@@ -22,7 +22,7 @@
 
     // Order corresponds to order in the UI.
     GrEditConstants.Actions = {
-      OPEN: {label: 'Open', id: 'open'},
+      OPEN: {label: 'Add/Open', id: 'open'},
       DELETE: {label: 'Delete', id: 'delete'},
       RENAME: {label: 'Rename', id: 'rename'},
       RESTORE: {label: 'Restore', id: 'restore'},
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
index 52692a7..cb950da 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -55,21 +55,13 @@
       gr-dialog .main > iron-input{
         width: 100%;
       }
-      gr-autocomplete {
-        --gr-autocomplete: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          height: 2em;
-          padding: 0 var(--spacing-xs);
-        }
-      }
       input {
         border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
-        height: 2em;
         margin: var(--spacing-m) 0;
-        padding: 0 var(--spacing-xs);
+        padding: var(--spacing-s);
         width: 100%;
+        box-sizing: content-box;
       }
       @media screen and (max-width: 50em) {
         gr-dialog {
@@ -89,12 +81,12 @@
           id="openDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Open"
+          confirm-label="Confirm"
           confirm-on-enter
           on-confirm="_handleOpenConfirm"
           on-cancel="_handleDialogCancel">
         <div class="header" slot="header">
-          Open an existing or new file
+          Add a new file or open an existing file
         </div>
         <div class="main" slot="main">
           <gr-autocomplete
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
index b7e12fe..e655f7b 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -17,47 +17,53 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-edit-controls',
+  /**
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @extends Polymer.Element
+   */
+  class GrEditControls extends Polymer.mixinBehaviors( [
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-edit-controls'; }
 
-    properties: {
-      change: Object,
-      patchNum: String,
+    static get properties() {
+      return {
+        change: Object,
+        patchNum: String,
 
-      /**
-       * TODO(kaspern): by default, the RESTORE action should be hidden in the
-       * file-list as it is a per-file action only. Remove this default value
-       * when the Actions dictionary is moved to a shared constants file and
-       * use the hiddenActions property in the parent component.
-       */
-      hiddenActions: {
-        type: Array,
-        value() { return [GrEditConstants.Actions.RESTORE.id]; },
-      },
-
-      _actions: {
-        type: Array,
-        value() { return Object.values(GrEditConstants.Actions); },
-      },
-      _path: {
-        type: String,
-        value: '',
-      },
-      _newPath: {
-        type: String,
-        value: '',
-      },
-      _query: {
-        type: Function,
-        value() {
-          return this._queryFiles.bind(this);
+        /**
+         * TODO(kaspern): by default, the RESTORE action should be hidden in the
+         * file-list as it is a per-file action only. Remove this default value
+         * when the Actions dictionary is moved to a shared constants file and
+         * use the hiddenActions property in the parent component.
+         */
+        hiddenActions: {
+          type: Array,
+          value() { return [GrEditConstants.Actions.RESTORE.id]; },
         },
-      },
-    },
 
-    behaviors: [
-      Gerrit.PatchSetBehavior,
-    ],
+        _actions: {
+          type: Array,
+          value() { return Object.values(GrEditConstants.Actions); },
+        },
+        _path: {
+          type: String,
+          value: '',
+        },
+        _newPath: {
+          type: String,
+          value: '',
+        },
+        _query: {
+          type: Function,
+          value() {
+            return this._queryFiles.bind(this);
+          },
+        },
+      };
+    }
 
     _handleTap(e) {
       e.preventDefault();
@@ -76,7 +82,7 @@
           this.openRestoreDialog();
           return;
       }
-    },
+    }
 
     /**
      * @param {string=} opt_path
@@ -84,7 +90,7 @@
     openOpenDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.openDialog);
-    },
+    }
 
     /**
      * @param {string=} opt_path
@@ -92,7 +98,7 @@
     openDeleteDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.deleteDialog);
-    },
+    }
 
     /**
      * @param {string=} opt_path
@@ -100,7 +106,7 @@
     openRenameDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.renameDialog);
-    },
+    }
 
     /**
      * @param {string=} opt_path
@@ -108,7 +114,7 @@
     openRestoreDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.restoreDialog);
-    },
+    }
 
     /**
      * Given a path string, checks that it is a valid file path.
@@ -119,11 +125,11 @@
     _isValidPath(path) {
       // Double negation needed for strict boolean return type.
       return !!path.length && !path.endsWith('/');
-    },
+    }
 
     _computeRenameDisabled(path, newPath) {
       return this._isValidPath(path) && this._isValidPath(newPath);
-    },
+    }
 
     /**
      * Given a dom event, gets the dialog that lies along this event path.
@@ -136,7 +142,7 @@
         if (!element.classList) { return false; }
         return element.classList.contains('dialog');
       });
-    },
+    }
 
     _showDialog(dialog) {
       // Some dialogs may not fire their on-close event when closed in certain
@@ -150,12 +156,12 @@
         if (autocomplete) { autocomplete.focus(); }
         this.async(() => { this.$.overlay.center(); }, 1);
       });
-    },
+    }
 
     _hideAllDialogs() {
       const dialogs = Polymer.dom(this.root).querySelectorAll('.dialog');
       for (const dialog of dialogs) { this._closeDialog(dialog); }
-    },
+    }
 
     /**
      * @param {Element|undefined} dialog
@@ -177,18 +183,18 @@
 
       dialog.classList.toggle('invisible', true);
       return this.$.overlay.close();
-    },
+    }
 
     _handleDialogCancel(e) {
       this._closeDialog(this._getDialogFromEvent(e));
-    },
+    }
 
     _handleOpenConfirm(e) {
       const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path,
           this.patchNum);
       Gerrit.Nav.navigateToRelativeUrl(url);
       this._closeDialog(this._getDialogFromEvent(e), true);
-    },
+    }
 
     _handleDeleteConfirm(e) {
       // Get the dialog before the api call as the event will change during bubbling
@@ -200,7 +206,7 @@
             this._closeDialog(dialog, true);
             Gerrit.Nav.navigateToChange(this.change);
           });
-    },
+    }
 
     _handleRestoreConfirm(e) {
       const dialog = this._getDialogFromEvent(e);
@@ -210,7 +216,7 @@
             this._closeDialog(dialog, true);
             Gerrit.Nav.navigateToChange(this.change);
           });
-    },
+    }
 
     _handleRenameConfirm(e) {
       const dialog = this._getDialogFromEvent(e);
@@ -220,17 +226,19 @@
         this._closeDialog(dialog, true);
         Gerrit.Nav.navigateToChange(this.change);
       });
-    },
+    }
 
     _queryFiles(input) {
       return this.$.restAPI.queryChangeFiles(this.change._number,
           this.patchNum, input).then(res => res.map(file => {
         return {name: file};
       }));
-    },
+    }
 
     _computeIsInvisible(id, hiddenActions) {
       return hiddenActions.includes(id) ? 'invisible' : '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEditControls.is, GrEditControls);
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
index dd8cb74..277bb78 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
@@ -17,7 +17,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-controls</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -82,7 +82,7 @@
     });
 
     test('open', () => {
-      MockInteractions.tap(element.$$('#open'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
       element.patchNum = 1;
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element._hideAllDialogs.called);
@@ -101,7 +101,7 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.$$('#open'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
         openAutoCcmplete.noDebounce = true;
@@ -129,7 +129,7 @@
 
     test('delete', () => {
       deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.$$('#delete'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -152,7 +152,7 @@
 
     test('delete fails', () => {
       deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.$$('#delete'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -173,7 +173,7 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.$$('#delete'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.deleteDialog.disabled);
         element.$.deleteDialog.querySelector('gr-autocomplete').text =
@@ -204,7 +204,7 @@
 
     test('rename', () => {
       renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.$$('#rename'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -232,7 +232,7 @@
 
     test('rename fails', () => {
       renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.$$('#rename'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         assert.isFalse(queryStub.called);
@@ -258,7 +258,7 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.$$('#rename'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.renameDialog.disabled);
         element.$.renameDialog.querySelector('gr-autocomplete').text =
@@ -285,13 +285,14 @@
     });
 
     test('restore hidden by default', () => {
-      assert.isTrue(element.$$('#restore').classList.contains('invisible'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('#restore').classList.contains('invisible'));
     });
 
     test('restore', () => {
       restoreStub.returns(Promise.resolve({ok: true}));
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.$$('#restore'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         MockInteractions.tap(element.$.restoreDialog.$$('gr-button[primary]'));
         flushAsynchronousOperations();
@@ -309,7 +310,7 @@
     test('restore fails', () => {
       restoreStub.returns(Promise.resolve({ok: false}));
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.$$('#restore'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         MockInteractions.tap(element.$.restoreDialog.$$('gr-button[primary]'));
         flushAsynchronousOperations();
@@ -325,7 +326,7 @@
 
     test('cancel', () => {
       element._path = 'src/test.cpp';
-      MockInteractions.tap(element.$$('#restore'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         MockInteractions.tap(element.$.restoreDialog.$$('gr-button'));
         assert.isFalse(navStub.called);
@@ -335,12 +336,15 @@
     });
   });
 
-  test('openOpenDialog', () => {
-    return element.openOpenDialog('test/path.cpp').then(() => {
-      assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-      assert.equal(element.$.openDialog.querySelector('gr-autocomplete').text,
-          'test/path.cpp');
-    });
+  test('openOpenDialog', done => {
+    element.openOpenDialog('test/path.cpp')
+        .then(() => {
+          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+          assert.equal(
+              element.$.openDialog.querySelector('gr-autocomplete').text,
+              'test/path.cpp');
+          done();
+        });
   });
 
   test('_getDialogFromEvent', () => {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
index 250816b..d59fcf7 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -17,38 +17,42 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-edit-file-controls',
-
+  /** @extends Polymer.Element */
+  class GrEditFileControls extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-edit-file-controls'; }
     /**
      * Fired when an action in the overflow menu is tapped.
      *
      * @event file-action-tap
      */
 
-    properties: {
-      filePath: String,
-      _allFileActions: {
-        type: Array,
-        value: () => Object.values(GrEditConstants.Actions),
-      },
-      _fileActions: {
-        type: Array,
-        computed: '_computeFileActions(_allFileActions)',
-      },
-    },
+    static get properties() {
+      return {
+        filePath: String,
+        _allFileActions: {
+          type: Array,
+          value: () => Object.values(GrEditConstants.Actions),
+        },
+        _fileActions: {
+          type: Array,
+          computed: '_computeFileActions(_allFileActions)',
+        },
+      };
+    }
 
     _handleActionTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this._dispatchFileAction(e.detail.id, this.filePath);
-    },
+    }
 
     _dispatchFileAction(action, path) {
       this.dispatchEvent(new CustomEvent(
           'file-action-tap',
           {detail: {action, path}, bubbles: true, composed: true}));
-    },
+    }
 
     _computeFileActions(actions) {
       // TODO(kaspern): conditionally disable some actions based on file status.
@@ -58,6 +62,8 @@
           id: action.id,
         };
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEditFileControls.is, GrEditFileControls);
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
index 7979e57..dc26468 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
@@ -17,7 +17,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-file-controls</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index bbcb90c..12e16cf 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -24,9 +24,22 @@
 
   const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
-  Polymer({
-    is: 'gr-editor-view',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @extends Polymer.Element
+   */
+  class GrEditorView extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.PathListBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-editor-view'; }
     /**
      * Fired when the title of the page should change.
      *
@@ -39,69 +52,71 @@
      * @event show-alert
      */
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * URL params passed from the router.
        */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
+        params: {
+          type: Object,
+          observer: '_paramsChanged',
+        },
 
-      _change: Object,
-      _changeEditDetail: Object,
-      _changeNum: String,
-      _patchNum: String,
-      _path: String,
-      _type: String,
-      _content: String,
-      _newContent: String,
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      _successfulSave: {
-        type: Boolean,
-        value: false,
-      },
-      _saveDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeSaveDisabled(_content, _newContent, _saving)',
-      },
-      _prefs: Object,
-    },
+        _change: Object,
+        _changeEditDetail: Object,
+        _changeNum: String,
+        _patchNum: String,
+        _path: String,
+        _type: String,
+        _content: String,
+        _newContent: String,
+        _saving: {
+          type: Boolean,
+          value: false,
+        },
+        _successfulSave: {
+          type: Boolean,
+          value: false,
+        },
+        _saveDisabled: {
+          type: Boolean,
+          value: true,
+          computed: '_computeSaveDisabled(_content, _newContent, _saving)',
+        },
+        _prefs: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.PathListBehavior,
-    ],
+    get keyBindings() {
+      return {
+        'ctrl+s meta+s': '_handleSaveShortcut',
+      };
+    }
 
-    listeners: {
-      'content-change': '_handleContentChange',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('content-change',
+          e => this._handleContentChange(e));
+    }
 
-    keyBindings: {
-      'ctrl+s meta+s': '_handleSaveShortcut',
-    },
-
+    /** @override */
     attached() {
+      super.attached();
       this._getEditPrefs().then(prefs => { this._prefs = prefs; });
-    },
+    }
 
     get storageKey() {
       return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _getEditPrefs() {
       return this.$.restAPI.getEditPreferences();
-    },
+    }
 
     _paramsChanged(value) {
       if (value.view !== Gerrit.Nav.View.EDIT) {
@@ -126,13 +141,13 @@
       promises.push(
           this._getFileData(this._changeNum, this._path, this._patchNum));
       return Promise.all(promises);
-    },
+    }
 
     _getChangeDetail(changeNum) {
       return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
         this._change = change;
       });
-    },
+    }
 
     _handlePathChanged(e) {
       const path = e.detail;
@@ -146,13 +161,13 @@
         this._successfulSave = true;
         this._viewEditInChangeView();
       });
-    },
+    }
 
     _viewEditInChangeView() {
       const patch = this._successfulSave ? this.EDIT_NAME : this._patchNum;
       Gerrit.Nav.navigateToChange(this._change, patch, null,
           patch !== this.EDIT_NAME);
-    },
+    }
 
     _getFileData(changeNum, path, patchNum) {
       const storedContent =
@@ -183,7 +198,7 @@
               this._type = '';
             }
           });
-    },
+    }
 
     _saveEdit() {
       this._saving = true;
@@ -198,7 +213,7 @@
         this._content = this._newContent;
         this._successfulSave = true;
       });
-    },
+    }
 
     _showAlert(message) {
       this.dispatchEvent(new CustomEvent('show-alert', {
@@ -206,7 +221,7 @@
         bubbles: true,
         composed: true,
       }));
-    },
+    }
 
     _computeSaveDisabled(content, newContent, saving) {
       // Polymer 2: check for undefined
@@ -222,12 +237,12 @@
         return true;
       }
       return content === newContent;
-    },
+    }
 
     _handleCloseTap() {
       // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
       this._viewEditInChangeView();
-    },
+    }
 
     _handleContentChange(e) {
       this.debounce('store', () => {
@@ -239,13 +254,15 @@
           this.$.storage.eraseEditableContentItem(this.storageKey);
         }
       }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    },
+    }
 
     _handleSaveShortcut(e) {
       e.preventDefault();
       if (!this._saveDisabled) {
         this._saveEdit();
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEditorView.is, GrEditorView);
 })();
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
index 226472f..ebd624e 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
@@ -17,7 +17,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editor-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/gr-app-element.html b/polygerrit-ui/app/elements/gr-app-element.html
index 046e5ff..f758280 100644
--- a/polygerrit-ui/app/elements/gr-app-element.html
+++ b/polygerrit-ui/app/elements/gr-app-element.html
@@ -211,7 +211,6 @@
     </footer>
     <gr-overlay id="keyboardShortcuts" with-backdrop>
       <gr-keyboard-shortcuts-dialog
-          view="[[params.view]]"
           on-close="_handleKeyboardShortcutDialogClose"></gr-keyboard-shortcuts-dialog>
     </gr-overlay>
     <gr-overlay id="registrationOverlay" with-backdrop>
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index ce6b98b..d39ba58 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -17,91 +17,92 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-app-element',
-
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrAppElement extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-app-element'; }
     /**
      * Fired when the URL location changes.
      *
      * @event location-change
      */
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * @type {{ query: string, view: string, screen: string }}
        */
-      params: Object,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
+        params: Object,
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
+        },
 
-      _account: {
-        type: Object,
-        observer: '_accountChanged',
-      },
+        _account: {
+          type: Object,
+          observer: '_accountChanged',
+        },
 
-      /**
-       * The last time the g key was pressed in milliseconds (or a keydown event
-       * was handled if the key is held down).
-       *
-       * @type {number|null}
-       */
-      _lastGKeyPressTimestamp: {
-        type: Number,
-        value: null,
-      },
+        /**
+         * The last time the g key was pressed in milliseconds (or a keydown event
+         * was handled if the key is held down).
+         *
+         * @type {number|null}
+         */
+        _lastGKeyPressTimestamp: {
+          type: Number,
+          value: null,
+        },
 
-      /**
-       * @type {{ plugin: Object }}
-       */
-      _serverConfig: Object,
-      _version: String,
-      _showChangeListView: Boolean,
-      _showDashboardView: Boolean,
-      _showChangeView: Boolean,
-      _showDiffView: Boolean,
-      _showSettingsView: Boolean,
-      _showAdminView: Boolean,
-      _showCLAView: Boolean,
-      _showEditorView: Boolean,
-      _showPluginScreen: Boolean,
-      _showDocumentationSearch: Boolean,
-      /** @type {?} */
-      _viewState: Object,
-      /** @type {?} */
-      _lastError: Object,
-      _lastSearchPage: String,
-      _path: String,
-      _pluginScreenName: {
-        type: String,
-        computed: '_computePluginScreenName(params)',
-      },
-      _settingsUrl: String,
-      _feedbackUrl: String,
-      // Used to allow searching on mobile
-      mobileSearch: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        /**
+         * @type {{ plugin: Object }}
+         */
+        _serverConfig: Object,
+        _version: String,
+        _showChangeListView: Boolean,
+        _showDashboardView: Boolean,
+        _showChangeView: Boolean,
+        _showDiffView: Boolean,
+        _showSettingsView: Boolean,
+        _showAdminView: Boolean,
+        _showCLAView: Boolean,
+        _showEditorView: Boolean,
+        _showPluginScreen: Boolean,
+        _showDocumentationSearch: Boolean,
+        /** @type {?} */
+        _viewState: Object,
+        /** @type {?} */
+        _lastError: Object,
+        _lastSearchPage: String,
+        _path: String,
+        _pluginScreenName: {
+          type: String,
+          computed: '_computePluginScreenName(params)',
+        },
+        _settingsUrl: String,
+        _feedbackUrl: String,
+        // Used to allow searching on mobile
+        mobileSearch: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    listeners: {
-      'page-error': '_handlePageError',
-      'title-change': '_handleTitleChange',
-      'location-change': '_handleLocationChange',
-      'rpc-log': '_handleRpcLog',
-    },
-
-    observers: [
-      '_viewChanged(params.view)',
-      '_paramsChanged(params.*)',
-    ],
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+    static get observers() {
+      return [
+        '_viewChanged(params.view)',
+        '_paramsChanged(params.*)',
+      ];
+    }
 
     keyboardShortcuts() {
       return {
@@ -112,14 +113,26 @@
         [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
         [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
       };
-    },
+    }
 
+    /** @override */
     created() {
+      super.created();
       this._bindKeyboardShortcuts();
-    },
+      this.addEventListener('page-error',
+          e => this._handlePageError(e));
+      this.addEventListener('title-change',
+          e => this._handleTitleChange(e));
+      this.addEventListener('location-change',
+          e => this._handleLocationChange(e));
+      this.addEventListener('rpc-log',
+          e => this._handleRpcLog(e));
+    }
 
+    /** @override */
     ready() {
-      this.$.reporting.appStarted(document.visibilityState === 'hidden');
+      super.ready();
+      this.$.reporting.appStarted();
       this.$.router.start();
 
       this.$.restAPI.getAccount().then(account => {
@@ -166,7 +179,7 @@
           selectedChangeIndex: 0,
         },
       };
-    },
+    }
 
     _bindKeyboardShortcuts() {
       this.bindShortcut(this.Shortcut.SEND_REPLY,
@@ -288,7 +301,7 @@
 
       this.bindShortcut(
           this.Shortcut.SEARCH, '/');
-    },
+    }
 
     _accountChanged(account) {
       if (!account) { return; }
@@ -299,7 +312,7 @@
       this.$.restAPI.getEditPreferences();
       this.$.errorManager.knownAccountId =
           this._account && this._account._account_id || null;
-    },
+    }
 
     _viewChanged(view) {
       this.$.errorView.classList.remove('show');
@@ -329,7 +342,7 @@
         });
       }
       this.$.header.unfloat();
-    },
+    }
 
     _handlePageError(e) {
       const props = [
@@ -357,7 +370,7 @@
           this._lastError = err;
         });
       }
-    },
+    }
 
     _handleLocationChange(e) {
       const hash = e.detail.hash.substring(1);
@@ -366,7 +379,7 @@
         pathname += '@' + hash;
       }
       this.set('_path', pathname);
-    },
+    }
 
     _paramsChanged(paramsRecord) {
       const params = paramsRecord.base;
@@ -374,7 +387,7 @@
       if (viewsToCheck.includes(params.view)) {
         this.set('_lastSearchPage', location.pathname);
       }
-    },
+    }
 
     _handleTitleChange(e) {
       if (e.detail.title) {
@@ -382,54 +395,54 @@
       } else {
         document.title = '';
       }
-    },
+    }
 
     _showKeyboardShortcuts(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       this.$.keyboardShortcuts.open();
-    },
+    }
 
     _handleKeyboardShortcutDialogClose() {
       this.$.keyboardShortcuts.close();
-    },
+    }
 
     _handleAccountDetailUpdate(e) {
       this.$.mainHeader.reload();
       if (this.params.view === Gerrit.Nav.View.SETTINGS) {
         this.$$('gr-settings-view').reloadAccountDetail();
       }
-    },
+    }
 
     _handleRegistrationDialogClose(e) {
       this.params.justRegistered = false;
       this.$.registrationOverlay.close();
-    },
+    }
 
     _goToOpenedChanges() {
       Gerrit.Nav.navigateToStatusSearch('open');
-    },
+    }
 
     _goToUserDashboard() {
       Gerrit.Nav.navigateToUserDashboard();
-    },
+    }
 
     _goToMergedChanges() {
       Gerrit.Nav.navigateToStatusSearch('merged');
-    },
+    }
 
     _goToAbandonedChanges() {
       Gerrit.Nav.navigateToStatusSearch('abandoned');
-    },
+    }
 
     _goToWatchedChanges() {
       // The query is hardcoded, and doesn't respect custom menu entries
       Gerrit.Nav.navigateToSearchQuery('is:watched is:open');
-    },
+    }
 
     _computePluginScreenName({plugin, screen}) {
       if (!plugin || !screen) return '';
       return `${plugin}-screen-${screen}`;
-    },
+    }
 
     _logWelcome() {
       console.group('Runtime Info');
@@ -442,7 +455,7 @@
         console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
       }
       console.groupEnd();
-    },
+    }
 
     /**
      * Intercept RPC log events emitted by REST API interfaces.
@@ -452,17 +465,19 @@
     _handleRpcLog(e) {
       this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
           e.detail.elapsed);
-    },
+    }
 
     _mobileSearchToggle(e) {
       this.mobileSearch = !this.mobileSearch;
-    },
+    }
 
     getThemeEndpoint() {
       // For now, we only have dark mode and light mode
       return window.localStorage.getItem('dark-theme') ?
         'app-theme-dark' :
         'app-theme-light';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAppElement.is, GrAppElement);
 })();
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index ac8ea1a..da54ac4 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -17,7 +17,12 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-app',
-  });
+  /** @extends Polymer.Element */
+  class GrApp extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-app'; }
+  }
+
+  customElements.define(GrApp.is, GrApp);
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 9f1b7f8..1014284 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -82,9 +82,7 @@
       sandbox.restore();
     });
 
-    appElement = () => {
-      return element.$['app-element'];
-    };
+    appElement = () => element.$['app-element'];
 
     test('reporting', () => {
       assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
index d1f8e56..4e27715 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
@@ -20,6 +20,7 @@
   // Prevent redefinition.
   if (window.GrAdminApi) { return; }
 
+  /** @constructor */
   function GrAdminApi(plugin) {
     this.plugin = plugin;
     plugin.on('admin-menu-links', this);
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
index 2537a37..10ba710 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
index 358fba7..0cff8e9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
@@ -17,6 +17,7 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrAttributeHelper(element) {
     this.element = element;
     this._promises = {};
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
index 0c4149c..c95128b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-attribute-helper</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
index 0454767..80abf23 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
@@ -17,6 +17,7 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrChangeMetadataApi(plugin) {
     this._hook = null;
     this.plugin = plugin;
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 2d07382..600421b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -17,6 +17,7 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrDomHooksManager(plugin) {
     this._plugin = plugin;
     this._hooks = {};
@@ -40,6 +41,7 @@
     return this._hooks[hookName];
   };
 
+  /** @constructor */
   function GrDomHook(hookName, opt_moduleName) {
     this._instances = [];
     this._callbacks = [];
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
index 9e657fa..a00dc68 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dom-hooks</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 3188f40..5a2f104 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -19,34 +19,41 @@
 
   const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
-  Polymer({
-    is: 'gr-endpoint-decorator',
+  /** @extends Polymer.Element */
+  class GrEndpointDecorator extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-endpoint-decorator'; }
 
-    properties: {
-      name: String,
-      /** @type {!Map} */
-      _domHooks: {
-        type: Map,
-        value() { return new Map(); },
-      },
-      /**
-       * This map prevents importing the same endpoint twice.
-       * Without caching, if a plugin is loaded after the loaded plugins
-       * callback fires, it will be imported twice and appear twice on the page.
-       *
-       * @type {!Map}
-       */
-      _initializedPlugins: {
-        type: Map,
-        value() { return new Map(); },
-      },
-    },
+    static get properties() {
+      return {
+        name: String,
+        /** @type {!Map} */
+        _domHooks: {
+          type: Map,
+          value() { return new Map(); },
+        },
+        /**
+         * This map prevents importing the same endpoint twice.
+         * Without caching, if a plugin is loaded after the loaded plugins
+         * callback fires, it will be imported twice and appear twice on the page.
+         *
+         * @type {!Map}
+         */
+        _initializedPlugins: {
+          type: Map,
+          value() { return new Map(); },
+        },
+      };
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       for (const [el, domHook] of this._domHooks) {
         domHook.handleInstanceDetached(el);
       }
-    },
+    }
 
     /**
      * @suppress {checkTypes}
@@ -55,7 +62,7 @@
       return new Promise((resolve, reject) => {
         (this.importHref || Polymer.importHref)(url, resolve, reject);
       });
-    },
+    }
 
     _initDecoration(name, plugin) {
       const el = document.createElement(name);
@@ -63,7 +70,7 @@
           this.getContentChildren().find(
               el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
           .then(el => this._appendChild(el));
-    },
+    }
 
     _initReplacement(name, plugin) {
       this.getContentChildNodes()
@@ -72,12 +79,12 @@
       const el = document.createElement(name);
       return this._initProperties(el, plugin).then(
           el => this._appendChild(el));
-    },
+    }
 
     _getEndpointParams() {
       return Array.from(
           Polymer.dom(this).querySelectorAll('gr-endpoint-param'));
-    },
+    }
 
     /**
      * @param {!Element} el
@@ -110,11 +117,11 @@
             clearTimeout(timeoutId);
             return el;
           });
-    },
+    }
 
     _appendChild(el) {
       return Polymer.dom(this.root).appendChild(el);
-    },
+    }
 
     _initModule({moduleName, plugin, type, domHook}) {
       const name = plugin.getPluginName() + '.' + moduleName;
@@ -138,18 +145,24 @@
         domHook.handleInstanceAttached(el);
         this._domHooks.set(el, domHook);
       });
-    },
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       Gerrit._endpoints.onNewEndpoint(this.name, this._initModule.bind(this));
-      Gerrit.awaitPluginsLoaded().then(() => Promise.all(
-          Gerrit._endpoints.getPlugins(this.name).map(
-              pluginUrl => this._import(pluginUrl)))
-      ).then(() =>
-        Gerrit._endpoints
-            .getDetails(this.name)
-            .forEach(this._initModule, this)
-      );
-    },
-  });
+      Gerrit.awaitPluginsLoaded()
+          .then(() => Promise.all(
+              Gerrit._endpoints.getPlugins(this.name).map(
+                  pluginUrl => this._import(pluginUrl)))
+          )
+          .then(() =>
+            Gerrit._endpoints
+                .getDetails(this.name)
+                .forEach(this._initModule, this)
+          );
+    }
+  }
+
+  customElements.define(GrEndpointDecorator.is, GrEndpointDecorator);
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index b0ad585..ae1c922 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-endpoint-decorator</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -96,10 +96,11 @@
       assert.equal(module['someparam'], 'barbar');
       return decorationHook.getLastAttached().then(element => {
         assert.strictEqual(element, module);
-      }).then(() => {
-        element.remove();
-        assert.equal(decorationHook.getAllAttached().length, 0);
-      });
+      })
+          .then(() => {
+            element.remove();
+            assert.equal(decorationHook.getAllAttached().length, 0);
+          });
     });
 
     test('replacement', () => {
@@ -109,12 +110,14 @@
           element => element.nodeName === 'OTHER-MODULE');
       assert.isOk(module);
       assert.equal(module['someparam'], 'foofoo');
-      return replacementHook.getLastAttached().then(element => {
-        assert.strictEqual(element, module);
-      }).then(() => {
-        element.remove();
-        assert.equal(replacementHook.getAllAttached().length, 0);
-      });
+      return replacementHook.getLastAttached()
+          .then(element => {
+            assert.strictEqual(element, module);
+          })
+          .then(() => {
+            element.remove();
+            assert.equal(replacementHook.getAllAttached().length, 0);
+          });
     });
 
     test('late registration', done => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
index c7a2d9a..bcad7f9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -17,17 +17,22 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-endpoint-param',
+  /** @extends Polymer.Element */
+  class GrEndpointParam extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-endpoint-param'; }
 
-    properties: {
-      name: String,
-      value: {
-        type: Object,
-        notify: true,
-        observer: '_valueChanged',
-      },
-    },
+    static get properties() {
+      return {
+        name: String,
+        value: {
+          type: Object,
+          notify: true,
+          observer: '_valueChanged',
+        },
+      };
+    }
 
     _valueChanged(newValue, oldValue) {
       /* In polymer 2 the following change was made:
@@ -42,6 +47,8 @@
         value: newValue,
       };
       this.dispatchEvent(new CustomEvent('value-changed', {detail}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEndpointParam.is, GrEndpointParam);
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
index 845c1e1..481a467 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
@@ -17,6 +17,7 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrEventHelper(element) {
     this.element = element;
     this._unsubscribers = [];
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
index bd76bd4..a98a0d2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-event-helper</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index e90ff30..2e3bee1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -17,20 +17,25 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-external-style',
+  /** @extends Polymer.Element */
+  class GrExternalStyle extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-external-style'; }
 
-    properties: {
-      name: String,
-      _urlsImported: {
-        type: Array,
-        value() { return []; },
-      },
-      _stylesApplied: {
-        type: Array,
-        value() { return []; },
-      },
-    },
+    static get properties() {
+      return {
+        name: String,
+        _urlsImported: {
+          type: Array,
+          value() { return []; },
+        },
+        _stylesApplied: {
+          type: Array,
+          value() { return []; },
+        },
+      };
+    }
 
     /**
      * @suppress {checkTypes}
@@ -41,14 +46,13 @@
       return new Promise((resolve, reject) => {
         (this.importHref || Polymer.importHref)(url, resolve, reject);
       });
-    },
+    }
 
     _applyStyle(name) {
       if (this._stylesApplied.includes(name)) { return; }
       this._stylesApplied.push(name);
-      // Hybrid custom-style syntax:
-      // https://polymer-library.polymer-project.org/2.0/docs/devguide/style-shadow-dom
-      const s = document.createElement('style', 'custom-style');
+
+      const s = document.createElement('style');
       s.setAttribute('include', name);
       const cs = document.createElement('custom-style');
       cs.appendChild(s);
@@ -57,7 +61,7 @@
       const topEl = document.getElementsByTagName('body')[0];
       topEl.insertBefore(cs, topEl.firstChild);
       Polymer.updateStyles();
-    },
+    }
 
     _importAndApply() {
       Promise.all(Gerrit._endpoints.getPlugins(this.name).map(
@@ -68,14 +72,20 @@
           this._applyStyle(name);
         }
       });
-    },
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this._importAndApply();
-    },
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       Gerrit.awaitPluginsLoaded().then(() => this._importAndApply());
-    },
-  });
+    }
+  }
+
+  customElements.define(GrExternalStyle.is, GrExternalStyle);
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
index 9566067..9f0c950 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-external-style</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index 6c66fbf..da050fb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -17,15 +17,20 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-plugin-host',
+  /** @extends Polymer.Element */
+  class GrPluginHost extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-plugin-host'; }
 
-    properties: {
-      config: {
-        type: Object,
-        observer: '_configChanged',
-      },
-    },
+    static get properties() {
+      return {
+        config: {
+          type: Object,
+          observer: '_configChanged',
+        },
+      };
+    }
 
     _configChanged(config) {
       const plugins = config.plugin;
@@ -49,7 +54,7 @@
       }
 
       Gerrit._loadPlugins(pluginsPending, pluginOpts);
-    },
+    }
 
     /**
      * Omit .js plugins that have .html counterparts.
@@ -60,6 +65,8 @@
         const counterpart = url.replace(/\.js$/, '.html');
         return !htmlPlugins.includes(counterpart);
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPluginHost.is, GrPluginHost);
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
index 3a8e4d8..91f151f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-host</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -81,7 +81,8 @@
 
     test('skip theme if preloaded', () => {
       sandbox.stub(Gerrit, '_isPluginPreloaded')
-          .withArgs('preloaded:gerrit-theme').returns(true);
+          .withArgs('preloaded:gerrit-theme')
+          .returns(true);
       sandbox.stub(Gerrit, '_loadPlugins');
       element.config = {
         default_theme: '/oof',
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
index 402d988..d084445 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -20,7 +20,9 @@
 
 <dom-module id="gr-plugin-popup">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <gr-overlay id="overlay" with-backdrop>
       <slot></slot>
     </gr-overlay>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
index 2e7a2b7..30bf6c8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -17,19 +17,24 @@
 (function(window) {
   'use strict';
 
-  Polymer({
-    is: 'gr-plugin-popup',
+  /** @extends Polymer.Element */
+  class GrPluginPopup extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-plugin-popup'; }
 
     get opened() {
       return this.$.overlay.opened;
-    },
+    }
 
     open() {
       return this.$.overlay.open();
-    },
+    }
 
     close() {
       this.$.overlay.close();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPluginPopup.is, GrPluginPopup);
 })(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
index 1f1e81e..e6f446b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-popup</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -56,9 +56,10 @@
       assert.isOk(element);
     });
 
-    test('open uses open() from gr-overlay', () => {
-      return element.open().then(() => {
+    test('open uses open() from gr-overlay', done => {
+      element.open().then(() => {
         assert.isTrue(element.$.overlay.open.called);
+        done();
       });
     });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
index 5418267..c3588a1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -23,6 +23,7 @@
    * opt_moduleName is a name of custom element that will be automatically
    * inserted on popup opening.
    *
+   * @constructor
    * @param {!Object} plugin
    * @param {opt_moduleName=} string
    */
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
index 53370e2..55b7ac5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-popup-interface</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -69,8 +69,8 @@
         instance = new GrPopupInterface(plugin);
       });
 
-      test('open', () => {
-        return instance.open().then(api => {
+      test('open', done => {
+        instance.open().then(api => {
           assert.strictEqual(api, instance);
           const manual = document.createElement('div');
           manual.id = 'foobar';
@@ -79,14 +79,16 @@
           flushAsynchronousOperations();
           assert.equal(
               container.querySelector('#foobar').textContent, 'manual content');
+          done();
         });
       });
 
-      test('close', () => {
-        return instance.open().then(api => {
+      test('close', done => {
+        instance.open().then(api => {
           assert.isTrue(api._getElement().node.opened);
           api.close();
           assert.isFalse(api._getElement().node.opened);
+          done();
         });
       });
     });
@@ -96,18 +98,20 @@
         instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
       });
 
-      test('open', () => {
-        return instance.open().then(api => {
+      test('open', done => {
+        instance.open().then(api => {
           assert.isNotNull(
               Polymer.dom(container).querySelector('gr-user-test-popup'));
+          done();
         });
       });
 
-      test('close', () => {
-        return instance.open().then(api => {
+      test('close', done => {
+        instance.open().then(api => {
           assert.isTrue(api._getElement().node.opened);
           api.close();
           assert.isFalse(api._getElement().node.opened);
+          done();
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
index 45f106d..b59cce6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
@@ -20,6 +20,7 @@
   // Prevent redefinition.
   if (window.GrRepoApi) { return; }
 
+  /** @constructor */
   function GrRepoApi(plugin) {
     this._hook = null;
     this.plugin = plugin;
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index 0b32f8a..5a47a85 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
index 49ff4ce..5ed4c1a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
@@ -17,11 +17,13 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrSettingsApi(plugin) {
     this._title = '(no title)';
     // Generate default screen URL token, specific to plugin, and unique(ish).
     this._token =
-      plugin.getPluginName() + Math.random().toString(36).substr(5);
+      plugin.getPluginName() + Math.random().toString(36)
+          .substr(5);
     this.plugin = plugin;
   }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
index cbc2de6..6248d78 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
index d5647ea..9ea5c8a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
@@ -22,6 +22,7 @@
 
   let styleObjectCount = 0;
 
+  /** @constructor */
   function GrStyleObject(rulesStr) {
     this._rulesStr = rulesStr;
     this._className = `__pg_js_api_class_${styleObjectCount}`;
@@ -64,20 +65,18 @@
     element.classList.add(this.getClassName(element));
   };
 
-
   function GrStylesApi() {
   }
 
   /**
    * Creates a new GrStyleObject with specified style properties.
    *
-   * @param {string} String with style properties.
+   * @param {string} ruleStr with style properties.
    * @return {GrStyleObject}
    */
   GrStylesApi.prototype.css = function(ruleStr) {
     return new GrStyleObject(ruleStr);
   };
 
-
   window.GrStylesApi = GrStylesApi;
 })(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
index 46bda6d..7d14e21 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-admin-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -105,7 +105,6 @@
       return [element1, element2, element3];
     }
 
-
     test('getClassName  - body level elements', () => {
       const bodyLevelElements = createNestedElements(document.body);
 
@@ -161,7 +160,6 @@
       assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
     }
 
-
     function assertAllElementsHaveDefaultStyle(elements) {
       for (const element of elements) {
         assert.equal(getComputedStyle(element).getPropertyValue('display'),
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
index 404fb9c..d145f52f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -20,6 +20,7 @@
   // Prevent redefinition.
   if (window.GrThemeApi) { return; }
 
+  /** @constructor */
   function GrThemeApi(plugin) {
     this.plugin = plugin;
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
index 6332b91..588facb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-theme-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -65,6 +65,7 @@
       setup(() => {
         fixture('header-title');
         stub('gr-custom-plugin-header', {
+          /** @override */
           ready() { customHeader = this; },
         });
         Gerrit._loadPlugins([]);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 662c6f1..e685030 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -40,7 +40,9 @@
         display: none;
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="gr-form-styles">
       <section>
         <span class="title"></span>
@@ -82,7 +84,6 @@
             hidden$="[[!usernameMutable]]"
             class="value">
           <iron-input
-              disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_username}}">
             <input
@@ -103,7 +104,6 @@
             hidden$="[[!nameMutable]]"
             class="value">
           <iron-input
-              disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_account.name}}">
             <input
@@ -119,7 +119,6 @@
         <span class="title">Status (e.g. "Vacation")</span>
         <span class="value">
           <iron-input
-              disabled="[[_saving]]"
               on-keydown="_handleKeydown"
               bind-value="{{_account.status}}">
             <input
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 3ba3a80..7bf641d 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -17,65 +17,72 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-account-info',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrAccountInfo extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-account-info'; }
     /**
      * Fired when account details are changed.
      *
      * @event account-detail-update
      */
 
-    properties: {
-      usernameMutable: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-      },
-      nameMutable: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeNameMutable(_serverConfig)',
-      },
-      hasUnsavedChanges: {
-        type: Boolean,
-        notify: true,
-        computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
+    static get properties() {
+      return {
+        usernameMutable: {
+          type: Boolean,
+          notify: true,
+          computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+        },
+        nameMutable: {
+          type: Boolean,
+          notify: true,
+          computed: '_computeNameMutable(_serverConfig)',
+        },
+        hasUnsavedChanges: {
+          type: Boolean,
+          notify: true,
+          computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
             '_hasUsernameChange, _hasStatusChange)',
-      },
+        },
 
-      _hasNameChange: Boolean,
-      _hasUsernameChange: Boolean,
-      _hasStatusChange: Boolean,
-      _loading: {
-        type: Boolean,
-        value: false,
-      },
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      _account: Object,
-      _serverConfig: Object,
-      _username: {
-        type: String,
-        observer: '_usernameChanged',
-      },
-      _avatarChangeUrl: {
-        type: String,
-        value: '',
-      },
-    },
+        _hasNameChange: Boolean,
+        _hasUsernameChange: Boolean,
+        _hasStatusChange: Boolean,
+        _loading: {
+          type: Boolean,
+          value: false,
+        },
+        _saving: {
+          type: Boolean,
+          value: false,
+        },
+        /** @type {?} */
+        _account: Object,
+        _serverConfig: Object,
+        _username: {
+          type: String,
+          observer: '_usernameChanged',
+        },
+        _avatarChangeUrl: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    observers: [
-      '_nameChanged(_account.name)',
-      '_statusChanged(_account.status)',
-    ],
+    static get observers() {
+      return [
+        '_nameChanged(_account.name)',
+        '_statusChanged(_account.status)',
+      ];
+    }
 
     loadData() {
       const promises = [];
@@ -104,7 +111,7 @@
       return Promise.all(promises).then(() => {
         this._loading = false;
       });
-    },
+    }
 
     save() {
       if (!this.hasUnsavedChanges) {
@@ -123,29 +130,29 @@
             this._saving = false;
             this.fire('account-detail-update');
           });
-    },
+    }
 
     _maybeSetName() {
       return this._hasNameChange && this.nameMutable ?
         this.$.restAPI.setAccountName(this._account.name) :
         Promise.resolve();
-    },
+    }
 
     _maybeSetUsername() {
       return this._hasUsernameChange && this.usernameMutable ?
         this.$.restAPI.setAccountUsername(this._username) :
         Promise.resolve();
-    },
+    }
 
     _maybeSetStatus() {
       return this._hasStatusChange ?
         this.$.restAPI.setAccountStatus(this._account.status) :
         Promise.resolve();
-    },
+    }
 
     _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
       return nameChanged || usernameChanged || statusChanged;
-    },
+    }
 
     _computeUsernameMutable(config, username) {
       // Polymer 2: check for undefined
@@ -159,34 +166,34 @@
       // Username may not be changed once it is set.
       return config.auth.editable_account_fields.includes('USER_NAME') &&
           !username;
-    },
+    }
 
     _computeNameMutable(config) {
       return config.auth.editable_account_fields.includes('FULL_NAME');
-    },
+    }
 
     _statusChanged() {
       if (this._loading) { return; }
       this._hasStatusChange = true;
-    },
+    }
 
     _usernameChanged() {
       if (this._loading || !this._account) { return; }
       this._hasUsernameChange =
           (this._account.username || '') !== (this._username || '');
-    },
+    }
 
     _nameChanged() {
       if (this._loading) { return; }
       this._hasNameChange = true;
-    },
+    }
 
     _handleKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this.save();
       }
-    },
+    }
 
     _hideAvatarChangeUrl(avatarChangeUrl) {
       if (!avatarChangeUrl) {
@@ -194,6 +201,8 @@
       }
 
       return '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccountInfo.is, GrAccountInfo);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index a35c1f0..a9056b91 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-info</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
index 852161c..74d92d3 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -32,7 +32,9 @@
         width: auto;
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="gr-form-styles">
       <table id="agreements">
         <thead>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
index 41595a98..67dc0c4 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -17,33 +17,43 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-agreements-list',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @extends Polymer.Element
+   */
+  class GrAgreementsList extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-agreements-list'; }
 
-    properties: {
-      _agreements: Array,
-    },
+    static get properties() {
+      return {
+        _agreements: Array,
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.loadData();
-    },
+    }
 
     loadData() {
       return this.$.restAPI.getAccountAgreements().then(agreements => {
         this._agreements = agreements;
       });
-    },
+    }
 
     getUrl() {
       return this.getBaseUrl() + '/settings/new-agreement';
-    },
+    }
 
     getUrlBase(item) {
       return this.getBaseUrl() + '/' + item;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAgreementsList.is, GrAgreementsList);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
index 14cf97c..cd5bc09 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
index 88a53ee..09a9dbc 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -24,7 +24,9 @@
 
 <dom-module id="gr-change-table-editor">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       #changeCols {
         width: auto;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 0fef3d62..8521126 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -17,23 +17,29 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-change-table-editor',
+  /**
+   * @appliesMixin Gerrit.ChangeTableMixin
+   * @extends Polymer.Element
+   */
+  class GrChangeTableEditor extends Polymer.mixinBehaviors( [
+    Gerrit.ChangeTableBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-change-table-editor'; }
 
-    properties: {
-      displayedColumns: {
-        type: Array,
-        notify: true,
-      },
-      showNumber: {
-        type: Boolean,
-        notify: true,
-      },
-    },
-
-    behaviors: [
-      Gerrit.ChangeTableBehavior,
-    ],
+    static get properties() {
+      return {
+        displayedColumns: {
+          type: Array,
+          notify: true,
+        },
+        showNumber: {
+          type: Boolean,
+          notify: true,
+        },
+      };
+    }
 
     /**
      * Get the list of enabled column names from whichever checkboxes are
@@ -46,7 +52,7 @@
           .querySelectorAll('.checkboxContainer input:not([name=number])'))
           .filter(checkbox => checkbox.checked)
           .map(checkbox => checkbox.name);
-    },
+    }
 
     /**
      * Handle a click on a checkbox container and relay the click to the checkbox it
@@ -56,7 +62,7 @@
       const checkbox = Polymer.dom(e.target).querySelector('input');
       if (!checkbox) { return; }
       checkbox.click();
-    },
+    }
 
     /**
      * Handle a click on the number checkbox and update the showNumber property
@@ -64,7 +70,7 @@
      */
     _handleNumberCheckboxClick(e) {
       this.showNumber = Polymer.dom(e).rootTarget.checked;
-    },
+    }
 
     /**
      * Handle a click on a displayed column checkboxes (excluding number) and
@@ -72,6 +78,8 @@
      */
     _handleTargetClick(e) {
       this.set('displayedColumns', this._getDisplayedColumns());
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeTableEditor.is, GrChangeTableEditor);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index 29a7081..ec37c03 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
index c29153e..fb5d64f 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.html
@@ -66,7 +66,9 @@
         max-width: 50em;
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <main>
       <h1>New Contributor Agreement</h1>
       <h3>Select an agreement type:</h3>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index cfd7b21..cff1d54 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -17,33 +17,42 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-cla-view',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrClaView extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-cla-view'; }
 
-    properties: {
-      _groups: Object,
-      /** @type {?} */
-      _serverConfig: Object,
-      _agreementsText: String,
-      _agreementName: String,
-      _signedAgreements: Array,
-      _showAgreements: {
-        type: Boolean,
-        value: false,
-      },
-      _agreementsUrl: String,
-    },
+    static get properties() {
+      return {
+        _groups: Object,
+        /** @type {?} */
+        _serverConfig: Object,
+        _agreementsText: String,
+        _agreementName: String,
+        _signedAgreements: Array,
+        _showAgreements: {
+          type: Boolean,
+          value: false,
+        },
+        _agreementsUrl: String,
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this.loadData();
 
       this.fire('title-change', {title: 'New Contributor Agreement'});
-    },
+    }
 
     loadData() {
       const promises = [];
@@ -52,9 +61,7 @@
       }));
 
       promises.push(this.$.restAPI.getAccountGroups().then(groups => {
-        this._groups = groups.sort((a, b) => {
-          return a.name.localeCompare(b.name);
-        });
+        this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
       }));
 
       promises.push(this.$.restAPI.getAccountAgreements().then(agreements => {
@@ -62,7 +69,7 @@
       }));
 
       return Promise.all(promises);
-    },
+    }
 
     _getAgreementsUrl(configUrl) {
       let url;
@@ -76,14 +83,14 @@
       }
 
       return url;
-    },
+    }
 
     _handleShowAgreement(e) {
       this._agreementName = e.target.getAttribute('data-name');
       this._agreementsUrl =
           this._getAgreementsUrl(e.target.getAttribute('data-url'));
       this._showAgreements = true;
-    },
+    }
 
     _handleSaveAgreements(e) {
       this._createToast('Agreement saving...');
@@ -99,16 +106,16 @@
         this._agreementsText = '';
         this._showAgreements = false;
       });
-    },
+    }
 
     _createToast(message) {
       this.dispatchEvent(new CustomEvent(
           'show-alert', {detail: {message}, bubbles: true, composed: true}));
-    },
+    }
 
     _computeShowAgreementsClass(agreements) {
       return agreements ? 'show' : '';
-    },
+    }
 
     _disableAgreements(item, groups, signedAgreements) {
       if (!groups) return false;
@@ -120,16 +127,16 @@
         }
       }
       return false;
-    },
+    }
 
     _hideAgreements(item, groups, signedAgreements) {
       return this._disableAgreements(item, groups, signedAgreements) ?
         '' : 'hide';
-    },
+    }
 
     _disableAgreementsText(text) {
       return text.toLowerCase() === 'i agree' ? false : true;
-    },
+    }
 
     // This checks for auto_verify_group,
     // if specified it returns 'hideAgreementsTextBox' which
@@ -150,6 +157,8 @@
           }
         }
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrClaView.is, GrClaView);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
index f1b65d9..13c4de4 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cla-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
index 53a30c3..80440c7 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
@@ -24,8 +24,12 @@
 
 <dom-module id="gr-edit-preferences">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div id="editPreferences" class="gr-form-styles">
       <section>
         <span class="title">Tab width</span>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
index 86350f9..9523136 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -17,66 +17,73 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-edit-preferences',
+  /** @extends Polymer.Element */
+  class GrEditPreferences extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-edit-preferences'; }
 
-    properties: {
-      hasUnsavedChanges: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
+    static get properties() {
+      return {
+        hasUnsavedChanges: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
 
-      /** @type {?} */
-      editPrefs: Object,
-    },
+        /** @type {?} */
+        editPrefs: Object,
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getEditPreferences().then(prefs => {
         this.editPrefs = prefs;
       });
-    },
+    }
 
     _handleEditPrefsChanged() {
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handleEditSyntaxHighlightingChanged() {
       this.set('editPrefs.syntax_highlighting',
           this.$.editSyntaxHighlighting.checked);
       this._handleEditPrefsChanged();
-    },
+    }
 
     _handleEditShowTabsChanged() {
       this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
       this._handleEditPrefsChanged();
-    },
+    }
 
     _handleMatchBracketsChanged() {
       this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
       this._handleEditPrefsChanged();
-    },
+    }
 
     _handleEditLineWrappingChanged() {
       this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
       this._handleEditPrefsChanged();
-    },
+    }
 
     _handleIndentWithTabsChanged() {
       this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
       this._handleEditPrefsChanged();
-    },
+    }
 
     _handleAutoCloseBracketsChanged() {
       this.set('editPrefs.auto_close_brackets',
           this.$.showAutoCloseBrackets.checked);
       this._handleEditPrefsChanged();
-    },
+    }
 
     save() {
       return this.$.restAPI.saveEditPreferences(this.editPrefs).then(res => {
         this.hasUnsavedChanges = false;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEditPreferences.is, GrEditPreferences);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
index c1c5c52..8b0bf86 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-edit-preferences</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
index caaf18b..041b2a7 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -23,7 +23,9 @@
 
 <dom-module id="gr-email-editor">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       th {
         color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index 8490b26..c60568c 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -17,33 +17,38 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-email-editor',
+  /** @extends Polymer.Element */
+  class GrEmailEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-email-editor'; }
 
-    properties: {
-      hasUnsavedChanges: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
+    static get properties() {
+      return {
+        hasUnsavedChanges: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
 
-      _emails: Array,
-      _emailsToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-      /** @type {?string} */
-      _newPreferred: {
-        type: String,
-        value: null,
-      },
-    },
+        _emails: Array,
+        _emailsToRemove: {
+          type: Array,
+          value() { return []; },
+        },
+        /** @type {?string} */
+        _newPreferred: {
+          type: String,
+          value: null,
+        },
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getAccountEmails().then(emails => {
         this._emails = emails;
       });
-    },
+    }
 
     save() {
       const promises = [];
@@ -62,7 +67,7 @@
         this._newPreferred = null;
         this.hasUnsavedChanges = false;
       });
-    },
+    }
 
     _handleDeleteButton(e) {
       const index = parseInt(Polymer.dom(e).localTarget
@@ -71,13 +76,13 @@
       this.push('_emailsToRemove', email);
       this.splice('_emails', index, 1);
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handlePreferredControlClick(e) {
       if (e.target.classList.contains('preferredControl')) {
         e.target.firstElementChild.click();
       }
-    },
+    }
 
     _handlePreferredChange(e) {
       const preferred = e.target.value;
@@ -90,6 +95,8 @@
           this.set(['_emails', i, 'preferred'], false);
         }
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEmailEditor.is, GrEmailEditor);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index 8d3f2d2..e55ac97 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-editor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
index cf73d99..7b8a191 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
@@ -26,7 +26,9 @@
 
 <dom-module id="gr-gpg-editor">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       .keyHeader {
         width: 9em;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
index 890061e..9f04915 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
@@ -17,27 +17,32 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-gpg-editor',
+  /** @extends Polymer.Element */
+  class GrGpgEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-gpg-editor'; }
 
-    properties: {
-      hasUnsavedChanges: {
-        type: Boolean,
-        value: false,
-        notify: true,
-      },
-      _keys: Array,
-      /** @type {?} */
-      _keyToView: Object,
-      _newKey: {
-        type: String,
-        value: '',
-      },
-      _keysToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-    },
+    static get properties() {
+      return {
+        hasUnsavedChanges: {
+          type: Boolean,
+          value: false,
+          notify: true,
+        },
+        _keys: Array,
+        /** @type {?} */
+        _keyToView: Object,
+        _newKey: {
+          type: String,
+          value: '',
+        },
+        _keysToRemove: {
+          type: Array,
+          value() { return []; },
+        },
+      };
+    }
 
     loadData() {
       this._keys = [];
@@ -52,7 +57,7 @@
               return gpgKey;
             });
       });
-    },
+    }
 
     save() {
       const promises = this._keysToRemove.map(key => {
@@ -63,18 +68,18 @@
         this._keysToRemove = [];
         this.hasUnsavedChanges = false;
       });
-    },
+    }
 
     _showKey(e) {
       const el = Polymer.dom(e).localTarget;
       const index = parseInt(el.getAttribute('data-index'), 10);
       this._keyToView = this._keys[index];
       this.$.viewKeyOverlay.open();
-    },
+    }
 
     _closeOverlay() {
       this.$.viewKeyOverlay.close();
-    },
+    }
 
     _handleDeleteKey(e) {
       const el = Polymer.dom(e).localTarget;
@@ -82,7 +87,7 @@
       this.push('_keysToRemove', this._keys[index]);
       this.splice('_keys', index, 1);
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handleAddKey() {
       this.$.addButton.disabled = true;
@@ -92,14 +97,17 @@
             this.$.newKey.disabled = false;
             this._newKey = '';
             this.loadData();
-          }).catch(() => {
+          })
+          .catch(() => {
             this.$.addButton.disabled = false;
             this.$.newKey.disabled = false;
           });
-    },
+    }
 
     _computeAddButtonDisabled(newKey) {
       return !newKey.length;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrGpgEditor.is, GrGpgEditor);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
index 9cfbde5f..c1c0d50 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-gpg-editor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -90,7 +90,7 @@
       const lastKey = keys[Object.keys(keys)[1]];
 
       const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
 
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
@@ -146,7 +146,7 @@
       };
 
       const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => { return Promise.resolve(newKeyObject); });
+          () => Promise.resolve(newKeyObject));
 
       element._newKey = newKeyString;
 
@@ -171,7 +171,7 @@
       const newKeyString = 'not even close to valid';
 
       const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-          () => { return Promise.reject(new Error('error')); });
+          () => Promise.reject(new Error('error')));
 
       element._newKey = newKeyString;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index ca500c8..e51294d 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -23,7 +23,9 @@
 
 <dom-module id="gr-group-list">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
         #groups .nameColumn {
           min-width: 11em;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index d62a241..c7b5faa 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -17,24 +17,27 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-group-list',
+  /** @extends Polymer.Element */
+  class GrGroupList extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-group-list'; }
 
-    properties: {
-      _groups: Array,
-    },
+    static get properties() {
+      return {
+        _groups: Array,
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getAccountGroups().then(groups => {
-        this._groups = groups.sort((a, b) => {
-          return a.name.localeCompare(b.name);
-        });
+        this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
       });
-    },
+    }
 
     _computeVisibleToAll(group) {
       return group.options.visible_to_all ? 'Yes' : 'No';
-    },
+    }
 
     _computeGroupPath(group) {
       if (!group || !group.id) { return; }
@@ -42,6 +45,8 @@
       // Group ID is already encoded from the API
       // Decode it here to match with our router encoding behavior
       return Gerrit.Nav.getUrlForGroup(decodeURIComponent(group.id));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrGroupList.is, GrGroupList);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 3c3ece3..d8bf888 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index 0cb9695..22ba457 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -56,7 +56,9 @@
         right: 2em;
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="gr-form-styles">
       <div hidden$="[[_passwordUrl]]">
         <section>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index 003e471..efd0c39 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -17,18 +17,25 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-http-password',
+  /** @extends Polymer.Element */
+  class GrHttpPassword extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-http-password'; }
 
-    properties: {
-      _username: String,
-      _generatedPassword: String,
-      _passwordUrl: String,
-    },
+    static get properties() {
+      return {
+        _username: String,
+        _generatedPassword: String,
+        _passwordUrl: String,
+      };
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this.loadData();
-    },
+    }
 
     loadData() {
       const promises = [];
@@ -42,7 +49,7 @@
       }));
 
       return Promise.all(promises);
-    },
+    }
 
     _handleGenerateTap() {
       this._generatedPassword = 'Generating...';
@@ -50,14 +57,16 @@
       this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
         this._generatedPassword = newPassword;
       });
-    },
+    }
 
     _closeOverlay() {
       this.$.generatedPasswordOverlay.close();
-    },
+    }
 
     _generatedPasswordOverlayClosed() {
       this._generatedPassword = '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrHttpPassword.is, GrHttpPassword);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index 8924058..d66b231 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -58,11 +58,9 @@
       const nextPassword = 'the new password';
       let generateResolve;
       const generateStub = sinon.stub(element.$.restAPI,
-          'generateAccountHttpPassword', () => {
-            return new Promise(resolve => {
-              generateResolve = resolve;
-            });
-          });
+          'generateAccountHttpPassword', () => new Promise(resolve => {
+            generateResolve = resolve;
+          }));
 
       assert.isNotOk(element._generatedPassword);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
index ee855cc..53d74f2 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.html
@@ -26,7 +26,9 @@
 
 <dom-module id="gr-identities">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       tr th.emailAddressHeader,
       tr th.identityHeader {
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
index c927f1e..ac4f9e4 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -22,61 +22,67 @@
     'OAUTH',
   ];
 
-  Polymer({
-    is: 'gr-identities',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @extends Polymer.Element
+   */
+  class GrIdentities extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-identities'; }
 
-    properties: {
-      _identities: Object,
-      _idName: String,
-      serverConfig: Object,
-      _showLinkAnotherIdentity: {
-        type: Boolean,
-        computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-      },
-    },
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
+    static get properties() {
+      return {
+        _identities: Object,
+        _idName: String,
+        serverConfig: Object,
+        _showLinkAnotherIdentity: {
+          type: Boolean,
+          computed: '_computeShowLinkAnotherIdentity(serverConfig)',
+        },
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getExternalIds().then(id => {
         this._identities = id;
       });
-    },
+    }
 
     _computeIdentity(id) {
       return id && id.startsWith('mailto:') ? '' : id;
-    },
+    }
 
     _computeHideDeleteClass(canDelete) {
       return canDelete ? 'show' : '';
-    },
+    }
 
     _handleDeleteItemConfirm() {
       this.$.overlay.close();
       return this.$.restAPI.deleteAccountIdentity([this._idName])
           .then(() => { this.loadData(); });
-    },
+    }
 
     _handleConfirmDialogCancel() {
       this.$.overlay.close();
-    },
+    }
 
     _handleDeleteItem(e) {
       const name = e.model.get('item.identity');
       if (!name) { return; }
       this._idName = name;
       this.$.overlay.open();
-    },
+    }
 
     _computeIsTrusted(item) {
       return item ? '' : 'Untrusted';
-    },
+    }
 
     filterIdentities(item) {
       return !item.identity.startsWith('username:');
-    },
+    }
 
     _computeShowLinkAnotherIdentity(config) {
       if (config && config.auth &&
@@ -86,7 +92,7 @@
       }
 
       return false;
-    },
+    }
 
     _computeLinkAnotherIdentity() {
       const baseUrl = this.getBaseUrl() || '';
@@ -95,6 +101,8 @@
         pathname = '/' + pathname.substring(baseUrl.length);
       }
       return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrIdentities.is, GrIdentities);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
index 1277424..e2f8cad 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-identities</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index 1485628..46fc165 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -44,7 +44,9 @@
         min-width: 23em;
       }
     </style>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="gr-form-styles">
       <table>
         <thead>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index 4f3c0c7..0ee232b 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -17,14 +17,19 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-menu-editor',
+  /** @extends Polymer.Element */
+  class GrMenuEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-menu-editor'; }
 
-    properties: {
-      menuItems: Array,
-      _newName: String,
-      _newUrl: String,
-    },
+    static get properties() {
+      return {
+        menuItems: Array,
+        _newName: String,
+        _newUrl: String,
+      };
+    }
 
     _handleMoveUpButton(e) {
       const index = Number(Polymer.dom(e).localTarget.dataset.index);
@@ -32,7 +37,7 @@
       const row = this.menuItems[index];
       const prev = this.menuItems[index - 1];
       this.splice('menuItems', index - 1, 2, row, prev);
-    },
+    }
 
     _handleMoveDownButton(e) {
       const index = Number(Polymer.dom(e).localTarget.dataset.index);
@@ -40,12 +45,12 @@
       const row = this.menuItems[index];
       const next = this.menuItems[index + 1];
       this.splice('menuItems', index, 2, next, row);
-    },
+    }
 
     _handleDeleteButton(e) {
       const index = Number(Polymer.dom(e).localTarget.dataset.index);
       this.splice('menuItems', index, 1);
-    },
+    }
 
     _handleAddButton() {
       if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
@@ -58,17 +63,19 @@
 
       this._newName = '';
       this._newUrl = '';
-    },
+    }
 
     _computeAddDisabled(newName, newUrl) {
       return !newName.length || !newUrl.length;
-    },
+    }
 
     _handleInputKeydown(e) {
       if (e.keyCode === 13) {
         e.stopPropagation();
         this._handleAddButton();
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrMenuEditor.is, GrMenuEditor);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index 134e018..a82b703 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -40,7 +40,7 @@
     let menu;
 
     function assertMenuNamesEqual(element, expected) {
-      const names = element.menuItems.map(i => { return i.name; });
+      const names = element.menuItems.map(i => i.name);
       assert.equal(names.length, expected.length);
       for (let i = 0; i < names.length; i++) {
         assert.equal(names[i], expected[i]);
@@ -53,7 +53,8 @@
       const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
           direction + 'Button';
       const button =
-          element.$$('tbody').querySelector(selector).$$('paper-button');
+          element.$$('tbody').querySelector(selector)
+              .$$('paper-button');
       MockInteractions.tap(button);
     }
 
@@ -147,14 +148,16 @@
 
       // Tap the delete button for the middle item.
       MockInteractions.tap(element.$$('tbody')
-          .querySelector('tr:nth-child(2) .remove-button').$$('paper-button'));
+          .querySelector('tr:nth-child(2) .remove-button')
+          .$$('paper-button'));
 
       assertMenuNamesEqual(element, ['first name', 'third name']);
 
       // Delete remaining items.
       for (let i = 0; i < 2; i++) {
         MockInteractions.tap(element.$$('tbody')
-            .querySelector('tr:first-child .remove-button').$$('paper-button'));
+            .querySelector('tr:first-child .remove-button')
+            .$$('paper-button'));
       }
       assertMenuNamesEqual(element, []);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index f366d2a..c289a49 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -26,7 +26,9 @@
 
 <dom-module id="gr-registration-dialog">
   <template>
-    <style include="gr-form-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       :host {
         display: block;
@@ -84,8 +86,7 @@
         <section>
           <div class="title">Full Name</div>
           <iron-input
-              bind-value="{{_account.name}}"
-              disabled="[[_saving]]">
+              bind-value="{{_account.name}}">
             <input
                 is="iron-input"
                 id="name"
@@ -96,8 +97,7 @@
         <section class$="[[_computeUsernameClass(_usernameMutable)]]">
           <div class="title">Username</div>
           <iron-input
-              bind-value="{{_account.username}}"
-              disabled="[[_saving]]">
+              bind-value="{{_account.username}}">
             <input
                 is="iron-input"
                 id="username"
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 0633416..4bb98d0 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -17,9 +17,16 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-registration-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrRegistrationDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-registration-dialog'; }
     /**
      * Fired when account details are changed.
      *
@@ -32,40 +39,40 @@
      * @event close
      */
 
-    properties: {
-      settingsUrl: String,
-      /** @type {?} */
-      _account: {
-        type: Object,
-        value: () => {
+    static get properties() {
+      return {
+        settingsUrl: String,
+        /** @type {?} */
+        _account: {
+          type: Object,
+          value: () => {
           // Prepopulate possibly undefined fields with values to trigger
           // computed bindings.
-          return {email: null, name: null, username: null};
+            return {email: null, name: null, username: null};
+          },
         },
-      },
-      _usernameMutable: {
-        type: Boolean,
-        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-        observer: '_loadingChanged',
-      },
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      _serverConfig: Object,
-    },
+        _usernameMutable: {
+          type: Boolean,
+          computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+          observer: '_loadingChanged',
+        },
+        _saving: {
+          type: Boolean,
+          value: false,
+        },
+        _serverConfig: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    hostAttributes: {
-      role: 'dialog',
-    },
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('role', 'dialog');
+    }
 
     loadData() {
       this._loading = true;
@@ -84,7 +91,7 @@
       return Promise.all([loadAccount, loadConfig]).then(() => {
         this._loading = false;
       });
-    },
+    }
 
     _save() {
       this._saving = true;
@@ -101,26 +108,26 @@
         this._saving = false;
         this.fire('account-detail-update');
       });
-    },
+    }
 
     _handleSave(e) {
       e.preventDefault();
       this._save().then(this.close.bind(this));
-    },
+    }
 
     _handleClose(e) {
       e.preventDefault();
       this.close();
-    },
+    }
 
     close() {
       this._saving = true; // disable buttons indefinitely
       this.fire('close');
-    },
+    }
 
     _computeSaveDisabled(name, email, saving) {
       return !name || !email || saving;
-    },
+    }
 
     _computeUsernameMutable(config, username) {
       // Polymer 2: check for undefined
@@ -133,14 +140,16 @@
 
       return config.auth.editable_account_fields.includes('USER_NAME') &&
           !username;
-    },
+    }
 
     _computeUsernameClass(usernameMutable) {
       return usernameMutable ? '' : 'hide';
-    },
+    }
 
     _loadingChanged() {
       this.classList.toggle('loading', this._loading);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRegistrationDialog.is, GrRegistrationDialog);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index d1b5c80..7f493c3 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-registration-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -146,11 +146,13 @@
         assert.equal(account.email, 'email');
 
         // Save and verify new values are committed.
-        save().then(() => {
-          assert.equal(account.name, 'new name');
-          assert.equal(account.username, 'new username');
-          assert.equal(account.email, 'email3');
-        }).then(done);
+        save()
+            .then(() => {
+              assert.equal(account.name, 'new name');
+              assert.equal(account.username, 'new username');
+              assert.equal(account.email, 'email3');
+            })
+            .then(done);
       });
     });
 
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
index dae3b68..bae1f38 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
@@ -17,12 +17,19 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-settings-item',
+  /** @extends Polymer.Element */
+  class GrSettingsItem extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-settings-item'; }
 
-    properties: {
-      anchor: String,
-      title: String,
-    },
-  });
+    static get properties() {
+      return {
+        anchor: String,
+        title: String,
+      };
+    }
+  }
+
+  customElements.define(GrSettingsItem.is, GrSettingsItem);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
index 846f776..c356e80 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.html
@@ -20,8 +20,12 @@
 
 <dom-module id="gr-settings-menu-item">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-page-nav-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-page-nav-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="navStyles">
       <li><a href$="[[href]]">[[title]]</a></li>
     </div>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
index 5db0031..d5a7eb7 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
@@ -17,12 +17,19 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-settings-menu-item',
+  /** @extends Polymer.Element */
+  class GrSettingsMenuItem extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-settings-menu-item'; }
 
-    properties: {
-      href: String,
-      title: String,
-    },
-  });
+    static get properties() {
+      return {
+        href: String,
+        title: String,
+      };
+    }
+  }
+
+  customElements.define(GrSettingsMenuItem.is, GrSettingsMenuItem);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index 74971cf..e71aef5 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -74,9 +74,15 @@
         margin-right: var(--spacing-l);
       }
     </style>
-    <style include="gr-form-styles"></style>
-    <style include="gr-menu-page-styles"></style>
-    <style include="gr-page-nav-styles"></style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-menu-page-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-page-nav-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
       <gr-page-nav class="navStyles">
@@ -131,7 +137,6 @@
         <fieldset id="profile">
           <gr-account-info
               id="accountInfo"
-              mutable="{{_accountNameMutable}}"
               has-unsaved-changes="{{_accountInfoChanged}}"></gr-account-info>
           <gr-button
               on-click="_handleSaveAccountInfo"
@@ -373,7 +378,6 @@
                   class="newEmailInput"
                   bind-value="{{_newEmail}}"
                   type="text"
-                  disabled="[[_addingEmail]]"
                   on-keydown="_handleNewEmailKeydown"
                   placeholder="email@example.com">
                 <input
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 714faab..78bad8c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -45,9 +45,20 @@
     'HTTP_LDAP',
   ];
 
-  Polymer({
-    is: 'gr-settings-view',
-
+  /**
+   * @appliesMixin Gerrit.DocsUrlMixin
+   * @appliesMixin Gerrit.ChangeTableMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrSettingsView extends Polymer.mixinBehaviors( [
+    Gerrit.DocsUrlBehavior,
+    Gerrit.ChangeTableBehavior,
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-settings-view'; }
     /**
      * Fired when the title of the page should change.
      *
@@ -60,104 +71,103 @@
      * @event show-alert
      */
 
-    properties: {
-      prefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      params: {
-        type: Object,
-        value() { return {}; },
-      },
-      _accountNameMutable: Boolean,
-      _accountInfoChanged: Boolean,
-      _changeTableColumnsNotDisplayed: Array,
-      /** @type {?} */
-      _localPrefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      _localChangeTableColumns: {
-        type: Array,
-        value() { return []; },
-      },
-      _localMenu: {
-        type: Array,
-        value() { return []; },
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _changeTableChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _prefsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      _diffPrefsChanged: Boolean,
-      /** @type {?} */
-      _editPrefsChanged: Boolean,
-      _menuChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _watchedProjectsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _keysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _gpgKeysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _newEmail: String,
-      _addingEmail: {
-        type: Boolean,
-        value: false,
-      },
-      _lastSentVerificationEmail: {
-        type: String,
-        value: null,
-      },
-      /** @type {?} */
-      _serverConfig: Object,
-      /** @type {?string} */
-      _docsBaseUrl: String,
-      _emailsChanged: Boolean,
+    static get properties() {
+      return {
+        prefs: {
+          type: Object,
+          value() { return {}; },
+        },
+        params: {
+          type: Object,
+          value() { return {}; },
+        },
+        _accountInfoChanged: Boolean,
+        _changeTableColumnsNotDisplayed: Array,
+        /** @type {?} */
+        _localPrefs: {
+          type: Object,
+          value() { return {}; },
+        },
+        _localChangeTableColumns: {
+          type: Array,
+          value() { return []; },
+        },
+        _localMenu: {
+          type: Array,
+          value() { return []; },
+        },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
+        _changeTableChanged: {
+          type: Boolean,
+          value: false,
+        },
+        _prefsChanged: {
+          type: Boolean,
+          value: false,
+        },
+        /** @type {?} */
+        _diffPrefsChanged: Boolean,
+        /** @type {?} */
+        _editPrefsChanged: Boolean,
+        _menuChanged: {
+          type: Boolean,
+          value: false,
+        },
+        _watchedProjectsChanged: {
+          type: Boolean,
+          value: false,
+        },
+        _keysChanged: {
+          type: Boolean,
+          value: false,
+        },
+        _gpgKeysChanged: {
+          type: Boolean,
+          value: false,
+        },
+        _newEmail: String,
+        _addingEmail: {
+          type: Boolean,
+          value: false,
+        },
+        _lastSentVerificationEmail: {
+          type: String,
+          value: null,
+        },
+        /** @type {?} */
+        _serverConfig: Object,
+        /** @type {?string} */
+        _docsBaseUrl: String,
+        _emailsChanged: Boolean,
 
-      /**
-       * For testing purposes.
-       */
-      _loadingPromise: Object,
+        /**
+         * For testing purposes.
+         */
+        _loadingPromise: Object,
 
-      _showNumber: Boolean,
+        _showNumber: Boolean,
 
-      _isDark: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        _isDark: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.DocsUrlBehavior,
-      Gerrit.ChangeTableBehavior,
-      Gerrit.FireBehavior,
-    ],
+    static get observers() {
+      return [
+        '_handlePrefsChanged(_localPrefs.*)',
+        '_handleMenuChanged(_localMenu.splices)',
+        '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
+      ];
+    }
 
-    observers: [
-      '_handlePrefsChanged(_localPrefs.*)',
-      '_handleMenuChanged(_localMenu.splices)',
-      '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       // Polymer 2: anchor tag won't work on shadow DOM
       // we need to manually calling scrollIntoView when hash changed
       this.listen(window, 'location-change', '_handleLocationChange');
@@ -221,11 +231,13 @@
         // Handle anchor tag for initial load
         this._handleLocationChange();
       });
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'location-change', '_handleLocationChange');
-    },
+    }
 
     _handleLocationChange() {
       // Handle anchor tag after dom attached
@@ -237,25 +249,25 @@
           elem.scrollIntoView();
         }
       }
-    },
+    }
 
     reloadAccountDetail() {
       Promise.all([
         this.$.accountInfo.loadData(),
         this.$.emailEditor.loadData(),
       ]);
-    },
+    }
 
     _isLoading() {
       return this._loading || this._loading === undefined;
-    },
+    }
 
     _copyPrefs(to, from) {
       for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
         this.set([to, PREFS_SECTION_FIELDS[i]],
             this[from][PREFS_SECTION_FIELDS[i]]);
       }
-    },
+    }
 
     _cloneMenu(prefs) {
       const menu = [];
@@ -267,7 +279,7 @@
         });
       }
       this._localMenu = menu;
-    },
+    }
 
     _cloneChangeTableColumns() {
       let columns = this.getVisibleColumns(this.prefs.change_table);
@@ -280,56 +292,56 @@
             this.prefs.change_table);
       }
       this._localChangeTableColumns = columns;
-    },
+    }
 
     _formatChangeTableColumns(changeTableArray) {
       return changeTableArray.map(item => {
         return {column: item};
       });
-    },
+    }
 
     _handleChangeTableChanged() {
       if (this._isLoading()) { return; }
       this._changeTableChanged = true;
-    },
+    }
 
     _handlePrefsChanged(prefs) {
       if (this._isLoading()) { return; }
       this._prefsChanged = true;
-    },
+    }
 
     _handleRelativeDateInChangeTable() {
       this.set('_localPrefs.relative_date_in_change_table',
           this.$.relativeDateInChangeTable.checked);
-    },
+    }
 
     _handleShowSizeBarsInFileListChanged() {
       this.set('_localPrefs.size_bar_in_change_table',
           this.$.showSizeBarsInFileList.checked);
-    },
+    }
 
     _handlePublishCommentsOnPushChanged() {
       this.set('_localPrefs.publish_comments_on_push',
           this.$.publishCommentsOnPush.checked);
-    },
+    }
 
     _handleWorkInProgressByDefault() {
       this.set('_localPrefs.work_in_progress_by_default',
           this.$.workInProgressByDefault.checked);
-    },
+    }
 
     _handleInsertSignedOff() {
       this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-    },
+    }
 
     _handleMenuChanged() {
       if (this._isLoading()) { return; }
       this._menuChanged = true;
-    },
+    }
 
     _handleSaveAccountInfo() {
       this.$.accountInfo.save();
-    },
+    }
 
     _handleSavePreferences() {
       this._copyPrefs('prefs', '_localPrefs');
@@ -337,7 +349,7 @@
       return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._prefsChanged = false;
       });
-    },
+    }
 
     _handleSaveChangeTable() {
       this.set('prefs.change_table', this._localChangeTableColumns);
@@ -346,15 +358,15 @@
       return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._changeTableChanged = false;
       });
-    },
+    }
 
     _handleSaveDiffPreferences() {
       this.$.diffPrefs.save();
-    },
+    }
 
     _handleSaveEditPreferences() {
       this.$.editPrefs.save();
-    },
+    }
 
     _handleSaveMenu() {
       this.set('prefs.my', this._localMenu);
@@ -362,7 +374,7 @@
       return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._menuChanged = false;
       });
-    },
+    }
 
     _handleResetMenuButton() {
       return this.$.restAPI.getDefaultPreferences().then(data => {
@@ -370,34 +382,34 @@
           this._cloneMenu(data.my);
         }
       });
-    },
+    }
 
     _handleSaveWatchedProjects() {
       this.$.watchedProjectsEditor.save();
-    },
+    }
 
     _computeHeaderClass(changed) {
       return changed ? 'edited' : '';
-    },
+    }
 
     _handleSaveEmails() {
       this.$.emailEditor.save();
-    },
+    }
 
     _handleNewEmailKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this._handleAddEmailButton();
       }
-    },
+    }
 
     _isNewEmailValid(newEmail) {
       return newEmail && newEmail.includes('@');
-    },
+    }
 
     _computeAddEmailButtonEnabled(newEmail, addingEmail) {
       return this._isNewEmailValid(newEmail) && !addingEmail;
-    },
+    }
 
     _handleAddEmailButton() {
       if (!this._isNewEmailValid(this._newEmail)) { return; }
@@ -412,7 +424,7 @@
         this._lastSentVerificationEmail = this._newEmail;
         this._newEmail = '';
       });
-    },
+    }
 
     _getFilterDocsLink(docsBaseUrl) {
       let base = docsBaseUrl;
@@ -424,7 +436,7 @@
       base = base.replace(TRAILING_SLASH_PATTERN, '');
 
       return base + GERRIT_DOCS_FILTER_PATH;
-    },
+    }
 
     _handleToggleDark() {
       if (this._isDark) {
@@ -440,7 +452,7 @@
       this.async(() => {
         window.location.reload();
       }, 1);
-    },
+    }
 
     _showHttpAuth(config) {
       if (config && config.auth &&
@@ -450,6 +462,8 @@
       }
 
       return false;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrSettingsView.is, GrSettingsView);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index 6dcf124..759e65b 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -70,7 +70,7 @@
 
     function stubAddAccountEmail(statusCode) {
       return sandbox.stub(element.$.restAPI, 'addAccountEmail',
-          () => { return Promise.resolve({status: statusCode}); });
+          () => Promise.resolve({status: statusCode}));
     }
 
     setup(done => {
@@ -487,9 +487,10 @@
 
       setup(() => {
         sandbox.stub(element.$.emailEditor, 'loadData');
-        sandbox.stub(element.$.restAPI, 'confirmEmail', () => {
-          return new Promise(resolve => { resolveConfirm = resolve; });
-        });
+        sandbox.stub(
+            element.$.restAPI,
+            'confirmEmail',
+            () => new Promise(resolve => { resolveConfirm = resolve; }));
         element.params = {emailToken: 'foo'};
         element.attached();
       });
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index 2a27194..dd02ccd 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -26,7 +26,9 @@
 
 <dom-module id="gr-ssh-editor">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       .statusHeader {
         width: 4em;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 874173a..44fb48c 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -17,33 +17,38 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-ssh-editor',
+  /** @extends Polymer.Element */
+  class GrSshEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-ssh-editor'; }
 
-    properties: {
-      hasUnsavedChanges: {
-        type: Boolean,
-        value: false,
-        notify: true,
-      },
-      _keys: Array,
-      /** @type {?} */
-      _keyToView: Object,
-      _newKey: {
-        type: String,
-        value: '',
-      },
-      _keysToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-    },
+    static get properties() {
+      return {
+        hasUnsavedChanges: {
+          type: Boolean,
+          value: false,
+          notify: true,
+        },
+        _keys: Array,
+        /** @type {?} */
+        _keyToView: Object,
+        _newKey: {
+          type: String,
+          value: '',
+        },
+        _keysToRemove: {
+          type: Array,
+          value() { return []; },
+        },
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getAccountSSHKeys().then(keys => {
         this._keys = keys;
       });
-    },
+    }
 
     save() {
       const promises = this._keysToRemove.map(key => {
@@ -54,22 +59,22 @@
         this._keysToRemove = [];
         this.hasUnsavedChanges = false;
       });
-    },
+    }
 
     _getStatusLabel(isValid) {
       return isValid ? 'Valid' : 'Invalid';
-    },
+    }
 
     _showKey(e) {
       const el = Polymer.dom(e).localTarget;
       const index = parseInt(el.getAttribute('data-index'), 10);
       this._keyToView = this._keys[index];
       this.$.viewKeyOverlay.open();
-    },
+    }
 
     _closeOverlay() {
       this.$.viewKeyOverlay.close();
-    },
+    }
 
     _handleDeleteKey(e) {
       const el = Polymer.dom(e).localTarget;
@@ -77,7 +82,7 @@
       this.push('_keysToRemove', this._keys[index]);
       this.splice('_keys', index, 1);
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handleAddKey() {
       this.$.addButton.disabled = true;
@@ -87,14 +92,17 @@
             this.$.newKey.disabled = false;
             this._newKey = '';
             this.push('_keys', key);
-          }).catch(() => {
+          })
+          .catch(() => {
             this.$.addButton.disabled = false;
             this.$.newKey.disabled = false;
           });
-    },
+    }
 
     _computeAddButtonDisabled(newKey) {
       return !newKey.length;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrSshEditor.is, GrSshEditor);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index d313f5a..d7644f7 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-ssh-editor</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -81,7 +81,7 @@
       const lastKey = keys[1];
 
       const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
-          () => { return Promise.resolve(); });
+          () => Promise.resolve());
 
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
@@ -132,7 +132,7 @@
       };
 
       const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => { return Promise.resolve(newKeyObject); });
+          () => Promise.resolve(newKeyObject));
 
       element._newKey = newKeyString;
 
@@ -157,7 +157,7 @@
       const newKeyString = 'not even close to valid';
 
       const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          () => { return Promise.reject(new Error('error')); });
+          () => Promise.reject(new Error('error')));
 
       element._newKey = newKeyString;
 
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
index 360ea2d..b1ecb2e 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -24,7 +24,9 @@
 
 <dom-module id="gr-watched-projects-editor">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="gr-form-styles">
       #watchedProjects .notifType {
         text-align: center;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index a40094d..df115ca 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -25,34 +25,39 @@
     {name: 'Abandons', key: 'notify_abandoned_changes'},
   ];
 
-  Polymer({
-    is: 'gr-watched-projects-editor',
+  /** @extends Polymer.Element */
+  class GrWatchedProjectsEditor extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-watched-projects-editor'; }
 
-    properties: {
-      hasUnsavedChanges: {
-        type: Boolean,
-        value: false,
-        notify: true,
-      },
-
-      _projects: Array,
-      _projectsToRemove: {
-        type: Array,
-        value() { return []; },
-      },
-      _query: {
-        type: Function,
-        value() {
-          return this._getProjectSuggestions.bind(this);
+    static get properties() {
+      return {
+        hasUnsavedChanges: {
+          type: Boolean,
+          value: false,
+          notify: true,
         },
-      },
-    },
+
+        _projects: Array,
+        _projectsToRemove: {
+          type: Array,
+          value() { return []; },
+        },
+        _query: {
+          type: Function,
+          value() {
+            return this._getProjectSuggestions.bind(this);
+          },
+        },
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getWatchedProjects().then(projs => {
         this._projects = projs;
       });
-    },
+    }
 
     save() {
       let deletePromise;
@@ -64,27 +69,25 @@
       }
 
       return deletePromise
-          .then(() => {
-            return this.$.restAPI.saveWatchedProjects(this._projects);
-          })
+          .then(() => this.$.restAPI.saveWatchedProjects(this._projects))
           .then(projects => {
             this._projects = projects;
             this._projectsToRemove = [];
             this.hasUnsavedChanges = false;
           });
-    },
+    }
 
     _getTypes() {
       return NOTIFICATION_TYPES;
-    },
+    }
 
     _getTypeCount() {
       return this._getTypes().length;
-    },
+    }
 
     _computeCheckboxChecked(project, key) {
       return project.hasOwnProperty(key);
-    },
+    }
 
     _getProjectSuggestions(input) {
       return this.$.restAPI.getSuggestedProjects(input)
@@ -99,7 +102,7 @@
             }
             return projects;
           });
-    },
+    }
 
     _handleRemoveProject(e) {
       const el = Polymer.dom(e).localTarget;
@@ -108,7 +111,7 @@
       this.splice('_projects', index, 1);
       this.push('_projectsToRemove', project);
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _canAddProject(project, text, filter) {
       if ((!project || !project.id) && !text) { return false; }
@@ -126,7 +129,7 @@
       }
 
       return true;
-    },
+    }
 
     _getNewProjectIndex(name, filter) {
       let i;
@@ -138,7 +141,7 @@
         }
       }
       return i;
-    },
+    }
 
     _handleAddProject() {
       const newProject = this.$.newProject.value;
@@ -158,7 +161,7 @@
       this.$.newProject.clear();
       this.$.newFilter.bindValue = '';
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handleCheckboxChange(e) {
       const el = Polymer.dom(e).localTarget;
@@ -167,11 +170,13 @@
       const checked = el.checked;
       this.set(['_projects', index, key], !!checked);
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handleNotifCellClick(e) {
       const checkbox = Polymer.dom(e.target).querySelector('input');
       if (checkbox) { checkbox.click(); }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrWatchedProjectsEditor.is, GrWatchedProjectsEditor);
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index 7a238ec..e28e858 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-settings-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -88,7 +88,7 @@
       function getKeysOfRow(row) {
         const boxes = rows[row].querySelectorAll('input[checked]');
         return Array.prototype.map.call(boxes,
-            e => { return e.getAttribute('data-key'); });
+            e => e.getAttribute('data-key'));
       }
 
       let checkedKeys = getKeysOfRow(0);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 10c876d..8cd2021 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -14,13 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-account-chip',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrAccountChip extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-account-chip'; }
     /**
      * Fired to indicate a key was pressed while this chip was focused.
      *
@@ -34,51 +40,54 @@
      * @event remove
      */
 
-    properties: {
-      account: Object,
-      additionalText: String,
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      removable: {
-        type: Boolean,
-        value: false,
-      },
-      showAvatar: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      transparentBackground: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        account: Object,
+        additionalText: String,
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        removable: {
+          type: Boolean,
+          value: false,
+        },
+        showAvatar: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+        transparentBackground: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
+    /** @override */
     ready() {
+      super.ready();
       this._getHasAvatars().then(hasAvatars => {
         this.showAvatar = hasAvatars;
       });
-    },
+    }
 
     _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
-    },
+    }
 
     _handleRemoveTap(e) {
       e.preventDefault();
       this.fire('remove', {account: this.account});
-    },
+    }
 
     _getHasAvatars() {
-      return this.$.restAPI.getConfig().then(cfg => {
-        return Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars));
-      });
-    },
-  });
+      return this.$.restAPI.getConfig()
+          .then(cfg => Promise.resolve(!!(
+            cfg && cfg.plugin && cfg.plugin.has_avatars
+          )));
+    }
+  }
+
+  customElements.define(GrAccountChip.is, GrAccountChip);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
index ae656fd..992ea8407 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.html
@@ -40,7 +40,8 @@
         on-commit="_handleInputCommit"
         clear-on-commit
         warn-uncommitted
-        text="{{_inputText}}">
+        text="{{_inputText}}"
+        vertical-offset="24">
     </gr-autocomplete>
   </template>
   <script src="gr-account-entry.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
index b2e0973..d2a111a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
@@ -20,10 +20,13 @@
   /**
    * gr-account-entry is an element for entering account
    * and/or group with autocomplete support.
+   *
+   * @extends Polymer.Element
    */
-  Polymer({
-    is: 'gr-account-entry',
-
+  class GrAccountEntry extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-account-entry'; }
     /**
      * Fired when an account is entered.
      *
@@ -37,65 +40,70 @@
      *
      * @event account-text-changed
      */
-    properties: {
-      allowAnyInput: Boolean,
-      borderless: Boolean,
-      placeholder: String,
 
-      // suggestFrom = 0 to enable default suggestions.
-      suggestFrom: {
-        type: Number,
-        value: 0,
-      },
+    static get properties() {
+      return {
+        allowAnyInput: Boolean,
+        borderless: Boolean,
+        placeholder: String,
 
-      /** @type {!function(string): !Promise<Array<{name, value}>>} */
-      querySuggestions: {
-        type: Function,
-        notify: true,
-        value() {
-          return input => Promise.resolve([]);
+        // suggestFrom = 0 to enable default suggestions.
+        suggestFrom: {
+          type: Number,
+          value: 0,
         },
-      },
 
-      _config: Object,
-      /** The value of the autocomplete entry. */
-      _inputText: {
-        type: String,
-        observer: '_inputTextChanged',
-      },
+        /** @type {!function(string): !Promise<Array<{name, value}>>} */
+        querySuggestions: {
+          type: Function,
+          notify: true,
+          value() {
+            return input => Promise.resolve([]);
+          },
+        },
 
-    },
+        _config: Object,
+        /** The value of the autocomplete entry. */
+        _inputText: {
+          type: String,
+          observer: '_inputTextChanged',
+        },
+
+      };
+    }
 
     get focusStart() {
       return this.$.input.focusStart;
-    },
+    }
 
     focus() {
       this.$.input.focus();
-    },
+    }
 
     clear() {
       this.$.input.clear();
-    },
+    }
 
     setText(text) {
       this.$.input.setText(text);
-    },
+    }
 
     getText() {
       return this.$.input.text;
-    },
+    }
 
     _handleInputCommit(e) {
       this.fire('add', {value: e.detail.value});
       this.$.input.focus();
-    },
+    }
 
     _inputTextChanged(text) {
       if (text.length && this.allowAnyInput) {
         this.dispatchEvent(new CustomEvent(
             'account-text-changed', {bubbles: true, composed: true}));
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccountEntry.is, GrAccountEntry);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
index 6896af9..3743820 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-entry</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -67,13 +67,11 @@
 
     suite('stubbed values for querySuggestions', () => {
       setup(() => {
-        element.querySuggestions = input => {
-          return Promise.resolve([
-            suggestion1,
-            suggestion2,
-            suggestion3,
-          ]);
-        };
+        element.querySuggestions = input => Promise.resolve([
+          suggestion1,
+          suggestion2,
+          suggestion3,
+        ]);
       });
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index 7ed7962..4bf1f1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -33,10 +33,9 @@
         content: var(--account-label-suffix);
       }
       gr-avatar {
-        height: 1.3em;
-        width: 1.3em;
-        margin-right: var(--spacing-xs);
-        vertical-align: -.25em;
+        height: var(--line-height-normal);
+        width: var(--line-height-normal);
+        vertical-align: top;
       }
       .text {
         @apply --gr-account-label-text-style;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 418d2ea..34c4cb6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -17,58 +17,67 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-account-label',
+  /**
+   * @appliesMixin Gerrit.DisplayNameMixin
+   * @appliesMixin Gerrit.TooltipMixin
+   * @extends Polymer.Element
+   */
+  class GrAccountLabel extends Polymer.mixinBehaviors( [
+    Gerrit.DisplayNameBehavior,
+    Gerrit.TooltipBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-account-label'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * @type {{ name: string, status: string }}
        */
-      account: Object,
-      avatarImageSize: {
-        type: Number,
-        value: 32,
-      },
-      title: {
-        type: String,
-        reflectToAttribute: true,
-        computed: '_computeAccountTitle(account, additionalText)',
-      },
-      additionalText: String,
-      hasTooltip: {
-        type: Boolean,
-        reflectToAttribute: true,
-        computed: '_computeHasTooltip(account)',
-      },
-      hideAvatar: {
-        type: Boolean,
-        value: false,
-      },
-      _serverConfig: {
-        type: Object,
-        value: null,
-      },
-    },
+        account: Object,
+        avatarImageSize: {
+          type: Number,
+          value: 32,
+        },
+        title: {
+          type: String,
+          reflectToAttribute: true,
+          computed: '_computeAccountTitle(account, additionalText)',
+        },
+        additionalText: String,
+        hasTooltip: {
+          type: Boolean,
+          reflectToAttribute: true,
+          computed: '_computeHasTooltip(account)',
+        },
+        hideAvatar: {
+          type: Boolean,
+          value: false,
+        },
+        _serverConfig: {
+          type: Object,
+          value: null,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.DisplayNameBehavior,
-      Gerrit.TooltipBehavior,
-    ],
-
+    /** @override */
     ready() {
+      super.ready();
       if (!this.additionalText) { this.additionalText = ''; }
       this.$.restAPI.getConfig()
           .then(config => { this._serverConfig = config; });
-    },
+    }
 
     _computeName(account, config) {
       return this.getUserName(config, account, false);
-    },
+    }
 
     _computeStatusTextLength(account, config) {
       // 35 as the max length of the name + status
       return Math.max(10, 35 - this._computeName(account, config).length);
-    },
+    }
 
     _computeAccountTitle(account, tooltip) {
       // Polymer 2: check for undefined
@@ -98,12 +107,12 @@
       }
 
       return result;
-    },
+    }
 
     _computeShowEmailClass(account) {
       if (!account || account.name || !account.email) { return ''; }
       return 'showEmail';
-    },
+    }
 
     _computeEmailStr(account) {
       if (!account || !account.email) {
@@ -113,11 +122,13 @@
         return '(' + account.email + ')';
       }
       return account.email;
-    },
+    }
 
     _computeHasTooltip(account) {
       // If an account has loaded to fire this method, then set to true.
       return !!account;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccountLabel.is, GrAccountLabel);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index 45545fe..f369ae2 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-label</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -64,22 +64,24 @@
     });
 
     test('computed fields', () => {
-      assert.equal(element._computeAccountTitle(
-          {
+      assert.equal(
+          element._computeAccountTitle({
             name: 'Andrew Bonventre',
             email: 'andybons+gerrit@gmail.com',
           }, /* additionalText= */ ''),
-      'Andrew Bonventre <andybons+gerrit@gmail.com>');
+          'Andrew Bonventre <andybons+gerrit@gmail.com>');
 
-      assert.equal(element._computeAccountTitle(
-          {name: 'Andrew Bonventre'}, /* additionalText= */ ''),
-      'Andrew Bonventre');
+      assert.equal(
+          element._computeAccountTitle({
+            name: 'Andrew Bonventre',
+          }, /* additionalText= */ ''),
+          'Andrew Bonventre');
 
-      assert.equal(element._computeAccountTitle(
-          {
+      assert.equal(
+          element._computeAccountTitle({
             email: 'andybons+gerrit@gmail.com',
           }, /* additionalText= */ ''),
-      'Anonymous <andybons+gerrit@gmail.com>');
+          'Anonymous <andybons+gerrit@gmail.com>');
 
       assert.equal(element._computeShowEmailClass(
           {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index faaf9c3..b0ce04c 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -17,27 +17,35 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-account-link',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @extends Polymer.Element
+   */
+  class GrAccountLink extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-account-link'; }
 
-    properties: {
-      additionalText: String,
-      account: Object,
-      avatarImageSize: {
-        type: Number,
-        value: 32,
-      },
-    },
-
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
+    static get properties() {
+      return {
+        additionalText: String,
+        account: Object,
+        avatarImageSize: {
+          type: Number,
+          value: 32,
+        },
+      };
+    }
 
     _computeOwnerLink(account) {
       if (!account) { return; }
       return Gerrit.Nav.getUrlForOwner(
           account.email || account.username || account.name ||
           account._account_id);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccountLink.is, GrAccountLink);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 134c579..e41304f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-link</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
index 2ce608be..37591d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.html
@@ -32,6 +32,7 @@
         display: flex;
         flex: 1;
         min-width: 10em;
+        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
       }
       .group {
         --account-label-suffix: ' (group)';
@@ -66,7 +67,6 @@
         borderless
         hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
         id="entry"
-        change="[[change]]"
         placeholder="[[placeholder]]"
         on-add="_handleAdd"
         on-input-keydown="_handleInputKeydown"
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
index de66c50..7955d50 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
@@ -19,100 +19,121 @@
 
   const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
-  Polymer({
-    is: 'gr-account-list',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrAccountList extends Polymer.mixinBehaviors( [
+    // Used in the tests for gr-account-list and other elements tests.
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-account-list'; }
     /**
      * Fired when user inputs an invalid email address.
      *
      * @event show-alert
      */
 
-    properties: {
-      accounts: {
-        type: Array,
-        value() { return []; },
-        notify: true,
-      },
-      change: Object,
-      filter: Function,
-      placeholder: String,
-      disabled: {
-        type: Function,
-        value: false,
-      },
-
-      /**
-       * Returns suggestions and convert them to list item
-       *
-       * @type {Gerrit.GrSuggestionsProvider}
-       */
-      suggestionsProvider: {
-        type: Object,
-      },
-
-      /**
-       * Needed for template checking since value is initially set to null.
-       *
-       * @type {?Object} */
-      pendingConfirmation: {
-        type: Object,
-        value: null,
-        notify: true,
-      },
-      readonly: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * When true, allows for non-suggested inputs to be added.
-       */
-      allowAnyInput: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Array of values (groups/accounts) that are removable. When this prop is
-       * undefined, all values are removable.
-       */
-      removableValues: Array,
-      maxCount: {
-        type: Number,
-        value: 0,
-      },
-
-      /** Returns suggestion items
-       *
-       * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
-       */
-      _querySuggestions: {
-        type: Function,
-        value() {
-          return this._getSuggestions.bind(this);
+    static get properties() {
+      return {
+        accounts: {
+          type: Array,
+          value() { return []; },
+          notify: true,
         },
-      },
-    },
+        change: Object,
+        filter: Function,
+        placeholder: String,
+        disabled: {
+          type: Function,
+          value: false,
+        },
 
-    behaviors: [
-      // Used in the tests for gr-account-list and other elements tests.
-      Gerrit.FireBehavior,
-    ],
+        /**
+         * Returns suggestions and convert them to list item
+         *
+         * @type {Gerrit.GrSuggestionsProvider}
+         */
+        suggestionsProvider: {
+          type: Object,
+        },
 
-    listeners: {
-      remove: '_handleRemove',
-    },
+        /**
+         * Needed for template checking since value is initially set to null.
+         *
+         * @type {?Object}
+         */
+        pendingConfirmation: {
+          type: Object,
+          value: null,
+          notify: true,
+        },
+        readonly: {
+          type: Boolean,
+          value: false,
+        },
+        /**
+         * When true, allows for non-suggested inputs to be added.
+         */
+        allowAnyInput: {
+          type: Boolean,
+          value: false,
+        },
+
+        /**
+         * Array of values (groups/accounts) that are removable. When this prop is
+         * undefined, all values are removable.
+         */
+        removableValues: Array,
+        maxCount: {
+          type: Number,
+          value: 0,
+        },
+
+        /**
+         * Returns suggestion items
+         *
+         * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
+         */
+        _querySuggestions: {
+          type: Function,
+          value() {
+            return this._getSuggestions.bind(this);
+          },
+        },
+
+        /**
+         * Set to true to disable suggestions on empty input.
+         */
+        skipSuggestOnEmpty: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
+
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('remove',
+          e => this._handleRemove(e));
+    }
 
     get accountChips() {
       return Array.from(
           Polymer.dom(this.root).querySelectorAll('gr-account-chip'));
-    },
+    }
 
     get focusStart() {
       return this.$.entry.focusStart;
-    },
+    }
 
     _getSuggestions(input) {
+      if (this.skipSuggestOnEmpty && !input) {
+        return Promise.resolve([]);
+      }
       const provider = this.suggestionsProvider;
       if (!provider) {
         return Promise.resolve([]);
@@ -125,11 +146,11 @@
         return suggestions.map(suggestion =>
           provider.makeSuggestionItem(suggestion));
       });
-    },
+    }
 
     _handleAdd(e) {
       this._addAccountItem(e.detail.value);
-    },
+    }
 
     _addAccountItem(item) {
       // Append new account or group to the accounts property. We add our own
@@ -165,14 +186,14 @@
       }
       this.pendingConfirmation = null;
       return true;
-    },
+    }
 
     confirmGroup(group) {
       group = Object.assign(
           {}, group, {confirmed: true, _pendingAdd: true, _group: true});
       this.push('accounts', group);
       this.pendingConfirmation = null;
-    },
+    }
 
     _computeChipClass(account) {
       const classes = [];
@@ -183,7 +204,7 @@
         classes.push('pendingAdd');
       }
       return classes.join(' ');
-    },
+    }
 
     _accountMatches(a, b) {
       if (a && b) {
@@ -195,7 +216,7 @@
         }
       }
       return a === b;
-    },
+    }
 
     _computeRemovable(account, readonly) {
       if (readonly) { return false; }
@@ -208,13 +229,13 @@
         return !!account._pendingAdd;
       }
       return true;
-    },
+    }
 
     _handleRemove(e) {
       const toRemove = e.detail.account;
       this._removeAccount(toRemove);
       this.$.entry.focus();
-    },
+    }
 
     _removeAccount(toRemove) {
       if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
@@ -234,12 +255,12 @@
         }
       }
       console.warn('received remove event for missing account', toRemove);
-    },
+    }
 
     _getNativeInput(paperInput) {
       // In Polymer 2 inputElement isn't nativeInput anymore
       return paperInput.$.nativeInput || paperInput.inputElement;
-    },
+    }
 
     _handleInputKeydown(e) {
       const input = this._getNativeInput(e.detail.input);
@@ -257,7 +278,7 @@
           }
           break;
       }
-    },
+    }
 
     _handleChipKeydown(e) {
       const chip = e.target;
@@ -295,7 +316,7 @@
           }
           break;
       }
-    },
+    }
 
     /**
      * Submit the text of the entry as a reviewer value, if it exists. If it is
@@ -311,22 +332,24 @@
       const wasSubmitted = this._addAccountItem(text);
       if (wasSubmitted) { this.$.entry.clear(); }
       return wasSubmitted;
-    },
+    }
 
     additions() {
-      return this.accounts.filter(account => {
-        return account._pendingAdd;
-      }).map(account => {
-        if (account._group) {
-          return {group: account};
-        } else {
-          return {account};
-        }
-      });
-    },
+      return this.accounts
+          .filter(account => account._pendingAdd)
+          .map(account => {
+            if (account._group) {
+              return {group: account};
+            } else {
+              return {account};
+            }
+          });
+    }
 
     _computeEntryHidden(maxCount, accountsRecord, readonly) {
       return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAccountList.is, GrAccountList);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
index f931a69..3eab3c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-account-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -187,7 +187,6 @@
         };
       });
 
-
       element._getSuggestions().then(suggestions => {
         // Default is no filtering.
         assert.equal(suggestions.length, 3);
@@ -198,11 +197,13 @@
           return suggestion._account_id === accountId;
         };
 
-        element._getSuggestions().then(suggestions => {
-          assert.deepEqual(suggestions,
-              [{name: originalSuggestions[0].email,
-                value: originalSuggestions[0]._account_id}]);
-        }).then(done);
+        element._getSuggestions()
+            .then(suggestions => {
+              assert.deepEqual(suggestions,
+                  [{name: originalSuggestions[0].email,
+                    value: originalSuggestions[0]._account_id}]);
+            })
+            .then(done);
       });
     });
 
@@ -225,7 +226,6 @@
       assert.isFalse(element._computeRemovable(existingAccount1, false));
       assert.isTrue(element._computeRemovable(newAccount, false));
 
-
       element.removableValues = [existingAccount1];
       assert.isTrue(element._computeRemovable(existingAccount1, false));
       assert.isTrue(element._computeRemovable(newAccount, false));
@@ -384,6 +384,57 @@
       });
     });
 
+    test('suggestion on empty', done => {
+      element.skipSuggestOnEmpty = false;
+      const suggestions = [
+        {
+          email: 'abc@example.com',
+          text: 'abcd',
+        },
+        {
+          email: 'qwe@example.com',
+          text: 'qwer',
+        },
+      ];
+      const getSuggestionsStub =
+          sandbox.stub(suggestionsProvider, 'getSuggestions')
+              .returns(Promise.resolve(suggestions));
+
+      const makeSuggestionItemStub =
+          sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
+
+      const input = element.$.entry.$.input;
+
+      input.text = '';
+      MockInteractions.focus(input.$.input);
+      input.noDebounce = true;
+      flushAsynchronousOperations();
+      flush(() => {
+        assert.isTrue(getSuggestionsStub.calledOnce);
+        assert.equal(getSuggestionsStub.lastCall.args[0], '');
+        assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+        done();
+      });
+    });
+
+    test('skip suggestion on empty', done => {
+      element.skipSuggestOnEmpty = true;
+      const getSuggestionsStub =
+          sandbox.stub(suggestionsProvider, 'getSuggestions')
+              .returns(Promise.resolve([]));
+
+      const input = element.$.entry.$.input;
+
+      input.text = '';
+      MockInteractions.focus(input.$.input);
+      input.noDebounce = true;
+      flushAsynchronousOperations();
+      flush(() => {
+        assert.isTrue(getSuggestionsStub.notCalled);
+        done();
+      });
+    });
+
     suite('allowAnyInput', () => {
       setup(() => {
         element.allowAnyInput = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index b0018df..0d44164 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -32,7 +32,7 @@
         background-color: var(--tooltip-background-color);
         bottom: 1.25rem;
         border-radius: var(--border-radius);
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        box-shadow: var(--elevation-level-2);
         color: var(--view-background-color);
         left: 1.25rem;
         position: fixed;
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index e7c8b2c..6a0769d 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -17,46 +17,56 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-alert',
-
+  /** @extends Polymer.Element */
+  class GrAlert extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-alert'; }
     /**
      * Fired when the action button is pressed.
      *
      * @event action
      */
 
-    properties: {
-      text: String,
-      actionText: String,
-      shown: {
-        type: Boolean,
-        value: true,
-        readOnly: true,
-        reflectToAttribute: true,
-      },
-      toast: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
+    static get properties() {
+      return {
+        text: String,
+        actionText: String,
+        /** @type {?string} */
+        type: String,
+        shown: {
+          type: Boolean,
+          value: true,
+          readOnly: true,
+          reflectToAttribute: true,
+        },
+        toast: {
+          type: Boolean,
+          value: true,
+          reflectToAttribute: true,
+        },
 
-      _hideActionButton: Boolean,
-      _boundTransitionEndHandler: {
-        type: Function,
-        value() { return this._handleTransitionEnd.bind(this); },
-      },
-      _actionCallback: Function,
-    },
+        _hideActionButton: Boolean,
+        _boundTransitionEndHandler: {
+          type: Function,
+          value() { return this._handleTransitionEnd.bind(this); },
+        },
+        _actionCallback: Function,
+      };
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this.addEventListener('transitionend', this._boundTransitionEndHandler);
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.removeEventListener('transitionend',
           this._boundTransitionEndHandler);
-    },
+    }
 
     show(text, opt_actionText, opt_actionCallback) {
       this.text = text;
@@ -65,31 +75,33 @@
       this._actionCallback = opt_actionCallback;
       Gerrit.getRootElement().appendChild(this);
       this._setShown(true);
-    },
+    }
 
     hide() {
       this._setShown(false);
       if (this._hasZeroTransitionDuration()) {
         Gerrit.getRootElement().removeChild(this);
       }
-    },
+    }
 
     _hasZeroTransitionDuration() {
       const style = window.getComputedStyle(this);
       // transitionDuration is always given in seconds.
       const duration = Math.round(parseFloat(style.transitionDuration) * 100);
       return duration === 0;
-    },
+    }
 
     _handleTransitionEnd(e) {
       if (this.shown) { return; }
 
       Gerrit.getRootElement().removeChild(this);
-    },
+    }
 
     _handleActionTap(e) {
       e.preventDefault();
       if (this._actionCallback) { this._actionCallback(); }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAlert.is, GrAlert);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 2338d55..bfcc431 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-alert</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
index 9208068..c12aa72 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -22,6 +22,7 @@
 <link rel="import" href="/bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="/bower_components/iron-fit-behavior/iron-fit-behavior.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+<script src="../../../types/polymer-behaviors.js"></script>
 <script src="../../../scripts/rootElement.js"></script>
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -58,7 +59,8 @@
       }
       .dropdown-content {
         background: var(--dropdown-background-color);
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        box-shadow: var(--elevation-level-2);
+        border-radius: var(--border-radius);
         max-height: 50vh;
         overflow: auto;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index b8c76ff..5ca95e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -17,9 +17,20 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-autocomplete-dropdown',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Polymer.IronFitMixin
+   * @extends Polymer.Element
+   */
+  class GrAutocompleteDropdown extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Polymer.IronFitBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-autocomplete-dropdown'; }
     /**
      * Fired when the dropdown is closed.
      *
@@ -32,57 +43,55 @@
      * @event item-selected
      */
 
-    properties: {
-      index: Number,
-      isHidden: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
-      verticalOffset: {
-        type: Number,
-        value: null,
-      },
-      horizontalOffset: {
-        type: Number,
-        value: null,
-      },
-      suggestions: {
-        type: Array,
-        value: () => [],
-        observer: '_resetCursorStops',
-      },
-      _suggestionEls: Array,
-    },
+    static get properties() {
+      return {
+        index: Number,
+        isHidden: {
+          type: Boolean,
+          value: true,
+          reflectToAttribute: true,
+        },
+        verticalOffset: {
+          type: Number,
+          value: null,
+        },
+        horizontalOffset: {
+          type: Number,
+          value: null,
+        },
+        suggestions: {
+          type: Array,
+          value: () => [],
+          observer: '_resetCursorStops',
+        },
+        _suggestionEls: Array,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Polymer.IronFitBehavior,
-    ],
-
-    keyBindings: {
-      up: '_handleUp',
-      down: '_handleDown',
-      enter: '_handleEnter',
-      esc: '_handleEscape',
-      tab: '_handleTab',
-    },
+    get keyBindings() {
+      return {
+        up: '_handleUp',
+        down: '_handleDown',
+        enter: '_handleEnter',
+        esc: '_handleEscape',
+        tab: '_handleTab',
+      };
+    }
 
     close() {
       this.isHidden = true;
-    },
+    }
 
     open() {
       this.isHidden = false;
       this._resetCursorStops();
       // Refit should run after we call Polymer.flush inside _resetCursorStops
       this.refit();
-    },
+    }
 
     getCurrentText() {
       return this.getCursorTarget().dataset.value;
-    },
+    }
 
     _handleUp(e) {
       if (!this.isHidden) {
@@ -90,7 +99,7 @@
         e.stopPropagation();
         this.cursorUp();
       }
-    },
+    }
 
     _handleDown(e) {
       if (!this.isHidden) {
@@ -98,19 +107,19 @@
         e.stopPropagation();
         this.cursorDown();
       }
-    },
+    }
 
     cursorDown() {
       if (!this.isHidden) {
         this.$.cursor.next();
       }
-    },
+    }
 
     cursorUp() {
       if (!this.isHidden) {
         this.$.cursor.previous();
       }
-    },
+    }
 
     _handleTab(e) {
       e.preventDefault();
@@ -119,7 +128,7 @@
         trigger: 'tab',
         selected: this.$.cursor.target,
       });
-    },
+    }
 
     _handleEnter(e) {
       e.preventDefault();
@@ -128,12 +137,12 @@
         trigger: 'enter',
         selected: this.$.cursor.target,
       });
-    },
+    }
 
     _handleEscape() {
       this._fireClose();
       this.close();
-    },
+    }
 
     _handleClickItem(e) {
       e.preventDefault();
@@ -147,15 +156,15 @@
         trigger: 'click',
         selected,
       });
-    },
+    }
 
     _fireClose() {
       this.fire('dropdown-closed');
-    },
+    }
 
     getCursorTarget() {
       return this.$.cursor.target;
-    },
+    }
 
     _resetCursorStops() {
       if (this.suggestions.length > 0) {
@@ -168,14 +177,16 @@
       } else {
         this._suggestionEls = [];
       }
-    },
+    }
 
     _resetCursorIndex() {
       this.$.cursor.setCursorAtIndex(0);
-    },
+    }
 
     _computeLabelClass(item) {
       return item.label ? '' : 'hide';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAutocompleteDropdown.is, GrAutocompleteDropdown);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
index a7b59d7..5f2ce94 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-autocomplete-dropdown</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index c9d12ce..e47b661 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -36,13 +36,14 @@
         margin: 0 var(--spacing-xs);
         vertical-align: top;
       }
-      paper-input:not(.borderless) {
-        border: 1px solid var(--border-color);
+      paper-input.borderless {
+        border: none;
+        padding: 0;
       }
       paper-input {
-        height: var(--line-height-normal);
-        width: 100%;
-        @apply --gr-autocomplete;
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s);
         --paper-input-container: {
           padding: 0;
         };
@@ -50,13 +51,25 @@
           font-size: var(--font-size-normal);
           line-height: var(--line-height-normal);
         };
+        /* This is a hack for not being able to set height:0 on the underline
+           of a paper-input 2.2.3 element. All the underline fixes below only
+           actually work in 3.x.x, so the height must be adjusted directly as
+           a workaround until we are on Polymer 3. */
+        height: var(--line-height-normal);
+        --paper-input-container-underline-height: 0;
+        --paper-input-container-underline-wrapper-height: 0;
+        --paper-input-container-underline-focus-height: 0;
+        --paper-input-container-underline-legacy-height: 0;
         --paper-input-container-underline: {
+          height: 0;
           display: none;
         };
         --paper-input-container-underline-focus: {
+          height: 0;
           display: none;
         };
         --paper-input-container-underline-disabled: {
+          height: 0;
           display: none;
         };
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index ee087cc..60985c1 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -20,9 +20,18 @@
   const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
   const DEBOUNCE_WAIT_MS = 200;
 
-  Polymer({
-    is: 'gr-autocomplete',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrAutocomplete extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-autocomplete'; }
     /**
      * Fired when a value is chosen.
      *
@@ -42,183 +51,190 @@
      * @event input-keydown
      */
 
-    properties: {
+    static get properties() {
+      return {
 
-      /**
-       * Query for requesting autocomplete suggestions. The function should
-       * accept the input as a string parameter and return a promise. The
-       * promise yields an array of suggestion objects with "name", "label",
-       * "value" properties. The "name" property will be displayed in the
-       * suggestion entry. The "label" property will, when specified, appear
-       * next to the "name" as label text. The "value" property will be emitted
-       * if that suggestion is selected.
-       *
-       * @type {function(string): Promise<?>}
-       */
-      query: {
-        type: Function,
-        value() {
-          return function() {
-            return Promise.resolve([]);
-          };
+        /**
+         * Query for requesting autocomplete suggestions. The function should
+         * accept the input as a string parameter and return a promise. The
+         * promise yields an array of suggestion objects with "name", "label",
+         * "value" properties. The "name" property will be displayed in the
+         * suggestion entry. The "label" property will, when specified, appear
+         * next to the "name" as label text. The "value" property will be emitted
+         * if that suggestion is selected.
+         *
+         * @type {function(string): Promise<?>}
+         */
+        query: {
+          type: Function,
+          value() {
+            return function() {
+              return Promise.resolve([]);
+            };
+          },
         },
-      },
 
-      /**
-       * The number of characters that must be typed before suggestions are
-       * made. If threshold is zero, default suggestions are enabled.
-       */
-      threshold: {
-        type: Number,
-        value: 1,
-      },
+        /**
+         * The number of characters that must be typed before suggestions are
+         * made. If threshold is zero, default suggestions are enabled.
+         */
+        threshold: {
+          type: Number,
+          value: 1,
+        },
 
-      allowNonSuggestedValues: Boolean,
-      borderless: Boolean,
-      disabled: Boolean,
-      showSearchIcon: {
-        type: Boolean,
-        value: false,
-      },
-      // Vertical offset needed for a 1em font-size with no vertical padding.
-      // Inputs with additional padding will need to increase vertical offset.
-      verticalOffset: {
-        type: Number,
-        value: 20,
-      },
+        allowNonSuggestedValues: Boolean,
+        borderless: Boolean,
+        disabled: Boolean,
+        showSearchIcon: {
+          type: Boolean,
+          value: false,
+        },
+        /**
+         * Vertical offset needed for an element with 20px line-height, 4px
+         * padding and 1px border (30px height total). Plus 1px spacing between
+         * input and dropdown. Inputs with different line-height or padding will
+         * need to tweak vertical offset.
+         */
+        verticalOffset: {
+          type: Number,
+          value: 31,
+        },
 
-      text: {
-        type: String,
-        value: '',
-        notify: true,
-      },
+        text: {
+          type: String,
+          value: '',
+          notify: true,
+        },
 
-      placeholder: String,
+        placeholder: String,
 
-      clearOnCommit: {
-        type: Boolean,
-        value: false,
-      },
+        clearOnCommit: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * When true, tab key autocompletes but does not fire the commit event.
-       * When false, tab key not caught, and focus is removed from the element.
-       * See Issue 4556, Issue 6645.
-       */
-      tabComplete: {
-        type: Boolean,
-        value: false,
-      },
+        /**
+         * When true, tab key autocompletes but does not fire the commit event.
+         * When false, tab key not caught, and focus is removed from the element.
+         * See Issue 4556, Issue 6645.
+         */
+        tabComplete: {
+          type: Boolean,
+          value: false,
+        },
 
-      value: {
-        type: String,
-        notify: true,
-      },
+        value: {
+          type: String,
+          notify: true,
+        },
 
-      /**
-       * Multi mode appends autocompleted entries to the value.
-       * If false, autocompleted entries replace value.
-       */
-      multi: {
-        type: Boolean,
-        value: false,
-      },
+        /**
+         * Multi mode appends autocompleted entries to the value.
+         * If false, autocompleted entries replace value.
+         */
+        multi: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * When true and uncommitted text is left in the autocomplete input after
-       * blurring, the text will appear red.
-       */
-      warnUncommitted: {
-        type: Boolean,
-        value: false,
-      },
+        /**
+         * When true and uncommitted text is left in the autocomplete input after
+         * blurring, the text will appear red.
+         */
+        warnUncommitted: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * When true, querying for suggestions is not debounced w/r/t keypresses
-       */
-      noDebounce: {
-        type: Boolean,
-        value: false,
-      },
+        /**
+         * When true, querying for suggestions is not debounced w/r/t keypresses
+         */
+        noDebounce: {
+          type: Boolean,
+          value: false,
+        },
 
-      /** @type {?} */
-      _suggestions: {
-        type: Array,
-        value() { return []; },
-      },
+        /** @type {?} */
+        _suggestions: {
+          type: Array,
+          value() { return []; },
+        },
 
-      _suggestionEls: {
-        type: Array,
-        value() { return []; },
-      },
+        _suggestionEls: {
+          type: Array,
+          value() { return []; },
+        },
 
-      _index: Number,
-      _disableSuggestions: {
-        type: Boolean,
-        value: false,
-      },
-      _focused: {
-        type: Boolean,
-        value: false,
-      },
+        _index: Number,
+        _disableSuggestions: {
+          type: Boolean,
+          value: false,
+        },
+        _focused: {
+          type: Boolean,
+          value: false,
+        },
 
-      /** The DOM element of the selected suggestion. */
-      _selected: Object,
-    },
+        /** The DOM element of the selected suggestion. */
+        _selected: Object,
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    observers: [
-      '_maybeOpenDropdown(_suggestions, _focused)',
-      '_updateSuggestions(text, threshold, noDebounce)',
-    ],
+    static get observers() {
+      return [
+        '_maybeOpenDropdown(_suggestions, _focused)',
+        '_updateSuggestions(text, threshold, noDebounce)',
+      ];
+    }
 
     get _nativeInput() {
       // In Polymer 2 inputElement isn't nativeInput anymore
       return this.$.input.$.nativeInput || this.$.input.inputElement;
-    },
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this.listen(document.body, 'click', '_handleBodyClick');
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(document.body, 'click', '_handleBodyClick');
       this.cancelDebouncer('update-suggestions');
-    },
+    }
 
     get focusStart() {
       return this.$.input;
-    },
+    }
 
     focus() {
       this._nativeInput.focus();
-    },
+    }
 
     selectAll() {
       const nativeInputElement = this._nativeInput;
       if (!this.$.input.value) { return; }
       nativeInputElement.setSelectionRange(0, this.$.input.value.length);
-    },
+    }
 
     clear() {
       this.text = '';
-    },
+    }
 
     _handleItemSelect(e) {
       // Let _handleKeydown deal with keyboard interaction.
       if (e.detail.trigger !== 'click') { return; }
       this._selected = e.detail.selected;
       this._commit();
-    },
+    }
 
     get _inputElement() {
       // Polymer2: this.$ can be undefined when this is first evaluated.
       return this.$ && this.$.input;
-    },
+    }
 
     /**
      * Set the text of the input without triggering the suggestion dropdown.
@@ -229,7 +245,7 @@
       this._disableSuggestions = true;
       this.text = text;
       this._disableSuggestions = false;
-    },
+    }
 
     _onInputFocus() {
       this._focused = true;
@@ -237,14 +253,14 @@
       this.$.input.classList.remove('warnUncommitted');
       // Needed so that --paper-input-container-input updated style is applied.
       this.updateStyles();
-    },
+    }
 
     _onInputBlur() {
       this.$.input.classList.toggle('warnUncommitted',
           this.warnUncommitted && this.text.length && !this._focused);
       // Needed so that --paper-input-container-input updated style is applied.
       this.updateStyles();
-    },
+    }
 
     _updateSuggestions(text, threshold, noDebounce) {
       // Polymer 2: check for undefined
@@ -252,13 +268,23 @@
         return;
       }
 
+      // Reset _suggestions for every update
+      // This will also prevent from carrying over suggestions:
+      // @see Issue 12039
+      this._suggestions = [];
+
+      // TODO(taoalpha): Also skip if text has not changed
+
       if (this._disableSuggestions) { return; }
       if (text.length < threshold) {
-        this._suggestions = [];
         this.value = '';
         return;
       }
 
+      if (!this._focused) {
+        return;
+      }
+
       const update = () => {
         this.query(text).then(suggestions => {
           if (text !== this.text) {
@@ -281,18 +307,18 @@
       } else {
         this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
       }
-    },
+    }
 
     _maybeOpenDropdown(suggestions, focused) {
       if (suggestions.length > 0 && focused) {
         return this.$.suggestions.open();
       }
       return this.$.suggestions.close();
-    },
+    }
 
     _computeClass(borderless) {
       return borderless ? 'borderless' : '';
-    },
+    }
 
     /**
      * _handleKeydown used for key handling in the this.$.input AND all child
@@ -339,7 +365,7 @@
           this._suggestions = [];
       }
       this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
-    },
+    }
 
     _cancel() {
       if (this._suggestions.length) {
@@ -347,19 +373,19 @@
       } else {
         this.fire('cancel');
       }
-    },
+    }
 
     /**
      * @param {boolean=} opt_tabComplete
      */
     _handleInputCommit(opt_tabComplete) {
       // Nothing to do if the dropdown is not open.
-      if (!this.allowNonSuggestedValues
-          && this.$.suggestions.isHidden) { return; }
+      if (!this.allowNonSuggestedValues &&
+          this.$.suggestions.isHidden) { return; }
 
       this._selected = this.$.suggestions.getCursorTarget();
       this._commit(opt_tabComplete);
-    },
+    }
 
     _updateValue(suggestion, suggestions) {
       if (!suggestion) { return; }
@@ -373,7 +399,7 @@
       } else {
         this.value = completed;
       }
-    },
+    }
 
     _handleBodyClick(e) {
       const eventPath = Polymer.dom(e).path;
@@ -383,13 +409,13 @@
         }
       }
       this._focused = false;
-    },
+    }
 
     _handleSuggestionTap(e) {
       e.stopPropagation();
       this.$.cursor.setCursor(e.target);
       this._commit();
-    },
+    }
 
     /**
      * Commits the suggestion, optionally firing the commit event.
@@ -425,10 +451,12 @@
       }
 
       this._textChangedSinceCommit = false;
-    },
+    }
 
     _computeShowSearchIconClass(showSearchIcon) {
       return showSearchIcon ? 'showSearchIcon' : '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAutocomplete.is, GrAutocomplete);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index ea1fd50..12bc82f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -38,6 +38,10 @@
   suite('gr-autocomplete tests', () => {
     let element;
     let sandbox;
+    const focusOnInput = element => {
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+    };
 
     setup(() => {
       element = fixture('basic');
@@ -50,19 +54,18 @@
 
     test('renders', () => {
       let promise;
-      const queryStub = sandbox.spy(input => {
-        return promise = Promise.resolve([
-          {name: input + ' 0', value: 0},
-          {name: input + ' 1', value: 1},
-          {name: input + ' 2', value: 2},
-          {name: input + ' 3', value: 3},
-          {name: input + ' 4', value: 4},
-        ]);
-      });
+      const queryStub = sandbox.spy(input => promise = Promise.resolve([
+        {name: input + ' 0', value: 0},
+        {name: input + ' 1', value: 1},
+        {name: input + ' 2', value: 2},
+        {name: input + ' 3', value: 3},
+        {name: input + ' 4', value: 4},
+      ]));
       element.query = queryStub;
       assert.isTrue(element.$.suggestions.isHidden);
       assert.equal(element.$.suggestions.$.cursor.index, -1);
 
+      focusOnInput(element);
       element.text = 'blah';
 
       assert.isTrue(queryStub.called);
@@ -99,11 +102,9 @@
 
     test('esc key behavior', done => {
       let promise;
-      const queryStub = sandbox.spy(() => {
-        return promise = Promise.resolve([
-          {name: 'blah', value: 123},
-        ]);
-      });
+      const queryStub = sandbox.spy(() => promise = Promise.resolve([
+        {name: 'blah', value: 123},
+      ]));
       element.query = queryStub;
 
       assert.isTrue(element.$.suggestions.isHidden);
@@ -130,15 +131,13 @@
 
     test('emits commit and handles cursor movement', done => {
       let promise;
-      const queryStub = sandbox.spy(input => {
-        return promise = Promise.resolve([
-          {name: input + ' 0', value: 0},
-          {name: input + ' 1', value: 1},
-          {name: input + ' 2', value: 2},
-          {name: input + ' 3', value: 3},
-          {name: input + ' 4', value: 4},
-        ]);
-      });
+      const queryStub = sandbox.spy(input => promise = Promise.resolve([
+        {name: input + ' 0', value: 0},
+        {name: input + ' 1', value: 1},
+        {name: input + ' 2', value: 2},
+        {name: input + ' 3', value: 3},
+        {name: input + ' 4', value: 4},
+      ]));
       element.query = queryStub;
 
       assert.isTrue(element.$.suggestions.isHidden);
@@ -183,9 +182,11 @@
     test('clear-on-commit behavior (off)', done => {
       let promise;
       const queryStub = sandbox.spy(() => {
-        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+        promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+        return promise;
       });
       element.query = queryStub;
+      focusOnInput(element);
       element.text = 'blah';
 
       promise.then(() => {
@@ -204,9 +205,11 @@
     test('clear-on-commit behavior (on)', done => {
       let promise;
       const queryStub = sandbox.spy(() => {
-        return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+        promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+        return promise;
       });
       element.query = queryStub;
+      focusOnInput(element);
       element.text = 'blah';
       element.clearOnCommit = true;
 
@@ -224,31 +227,24 @@
     });
 
     test('threshold guards the query', () => {
-      const queryStub = sandbox.spy(() => {
-        return Promise.resolve([]);
-      });
+      const queryStub = sandbox.spy(() => Promise.resolve([]));
       element.query = queryStub;
-
       element.threshold = 2;
-
+      focusOnInput(element);
       element.text = 'a';
-
       assert.isFalse(queryStub.called);
-
       element.text = 'ab';
-
       assert.isTrue(queryStub.called);
     });
 
     test('noDebounce=false debounces the query', () => {
-      const queryStub = sandbox.spy(() => {
-        return Promise.resolve([]);
-      });
+      const queryStub = sandbox.spy(() => Promise.resolve([]));
       let callback;
       const debounceStub = sandbox.stub(element, 'debounce',
           (name, cb) => { callback = cb; });
       element.query = queryStub;
       element.noDebounce = false;
+      focusOnInput(element);
       element.text = 'a';
       assert.isFalse(queryStub.called);
       assert.isTrue(debounceStub.called);
@@ -269,11 +265,68 @@
       assert.equal(element._suggestions.length, 0);
     });
 
+    test('when focused', done => {
+      let promise;
+      const queryStub = sandbox.stub()
+          .returns(promise = Promise.resolve([
+            {name: 'suggestion', value: 0},
+          ]));
+      element.query = queryStub;
+      element.suggestOnlyWhenFocus = true;
+      focusOnInput(element);
+      element.text = 'bla';
+      assert.equal(element._focused, true);
+      flushAsynchronousOperations();
+      promise.then(() => {
+        assert.equal(element._suggestions.length, 1);
+        assert.equal(queryStub.notCalled, false);
+        done();
+      });
+    });
+
+    test('when not focused', done => {
+      let promise;
+      const queryStub = sandbox.stub()
+          .returns(promise = Promise.resolve([
+            {name: 'suggestion', value: 0},
+          ]));
+      element.query = queryStub;
+      element.suggestOnlyWhenFocus = true;
+      element.text = 'bla';
+      assert.equal(element._focused, false);
+      flushAsynchronousOperations();
+      promise.then(() => {
+        assert.equal(element._suggestions.length, 0);
+        done();
+      });
+    });
+
+    test('suggestions should not carry over', done => {
+      let promise;
+      const queryStub = sandbox.stub()
+          .returns(promise = Promise.resolve([
+            {name: 'suggestion', value: 0},
+          ]));
+      element.query = queryStub;
+      focusOnInput(element);
+      element.text = 'bla';
+      flushAsynchronousOperations();
+      promise.then(() => {
+        assert.equal(element._suggestions.length, 1);
+        element._updateSuggestions('', 0, false);
+        assert.equal(element._suggestions.length, 0);
+        done();
+      });
+    });
+
     test('multi completes only the last part of the query', done => {
       let promise;
       const queryStub = sandbox.stub()
-          .returns(promise = Promise.resolve([{name: 'suggestion', value: 0}]));
+          .returns(promise = Promise.resolve([
+            {name: 'suggestion', value: 0},
+          ]));
       element.query = queryStub;
+      focusOnInput(element);
       element.text = 'blah blah';
       element.multi = true;
 
@@ -335,7 +388,7 @@
     });
 
     test('vertical offset overridden by param if it exists', () => {
-      assert.equal(element.$.suggestions.verticalOffset, 20);
+      assert.equal(element.$.suggestions.verticalOffset, 31);
       element.verticalOffset = 30;
       assert.equal(element.$.suggestions.verticalOffset, 30);
     });
@@ -396,7 +449,12 @@
       // Must set the value, because the MockInteraction does not.
       element.$.input.value = 'file:x';
       assert.isTrue(keydownSpy.calledOnce);
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null, 'enter');
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.input,
+          13,
+          null,
+          'enter'
+      );
       assert.isTrue(keydownSpy.calledTwice);
       assert.equal(element.text, 'file:x');
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index bf56382..efa97cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -17,29 +17,37 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-avatar',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @extends Polymer.Element
+   */
+  class GrAvatar extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-avatar'; }
 
-    properties: {
-      account: {
-        type: Object,
-        observer: '_accountChanged',
-      },
-      imageSize: {
-        type: Number,
-        value: 16,
-      },
-      _hasAvatars: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        account: {
+          type: Object,
+          observer: '_accountChanged',
+        },
+        imageSize: {
+          type: Number,
+          value: 16,
+        },
+        _hasAvatars: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       Promise.all([
         this._getConfig(),
         Gerrit.awaitPluginsLoaded(),
@@ -48,15 +56,15 @@
 
         this._updateAvatarURL();
       });
-    },
+    }
 
     _getConfig() {
       return this.$.restAPI.getConfig();
-    },
+    }
 
     _accountChanged(account) {
       this._updateAvatarURL();
-    },
+    }
 
     _updateAvatarURL() {
       if (!this._hasAvatars || !this.account) {
@@ -69,12 +77,12 @@
       if (url) {
         this.style.backgroundImage = 'url("' + url + '")';
       }
-    },
+    }
 
     _getAccounts(account) {
       return account._account_id || account.email || account.username ||
           account.name;
-    },
+    }
 
     _buildAvatarURL(account) {
       if (!account) { return ''; }
@@ -87,6 +95,8 @@
       return this.getBaseUrl() + '/accounts/' +
         encodeURIComponent(this._getAccounts(account)) +
         '/avatar?s=' + this.imageSize;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrAvatar.is, GrAvatar);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index de05a5c..4d49206 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-avatar</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -49,28 +49,28 @@
     });
 
     test('methods', () => {
-      assert.equal(element._buildAvatarURL(
-          {
+      assert.equal(
+          element._buildAvatarURL({
             _account_id: 123,
           }),
-      '/accounts/123/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+          '/accounts/123/avatar?s=16');
+      assert.equal(
+          element._buildAvatarURL({
             email: 'test@example.com',
           }),
-      '/accounts/test%40example.com/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+          '/accounts/test%40example.com/avatar?s=16');
+      assert.equal(
+          element._buildAvatarURL({
             name: 'John Doe',
           }),
-      '/accounts/John%20Doe/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+          '/accounts/John%20Doe/avatar?s=16');
+      assert.equal(
+          element._buildAvatarURL({
             username: 'John_Doe',
           }),
-      '/accounts/John_Doe/avatar?s=16');
-      assert.equal(element._buildAvatarURL(
-          {
+          '/accounts/John_Doe/avatar?s=16');
+      assert.equal(
+          element._buildAvatarURL({
             _account_id: 123,
             avatars: [
               {
@@ -87,9 +87,9 @@
               },
             ],
           }),
-      'https://cdn.example.com/s16-p/photo.jpg');
-      assert.equal(element._buildAvatarURL(
-          {
+          'https://cdn.example.com/s16-p/photo.jpg');
+      assert.equal(
+          element._buildAvatarURL({
             _account_id: 123,
             avatars: [
               {
@@ -98,16 +98,17 @@
               },
             ],
           }),
-      '/accounts/123/avatar?s=16');
+          '/accounts/123/avatar?s=16');
       assert.equal(element._buildAvatarURL(undefined), '');
     });
 
     test('dom for existing account', () => {
       assert.isFalse(element.hasAttribute('hidden'));
 
-      sandbox.stub(element, '_getConfig', () => {
-        return Promise.resolve({plugin: {has_avatars: true}});
-      });
+      sandbox.stub(
+          element,
+          '_getConfig',
+          () => Promise.resolve({plugin: {has_avatars: true}}));
 
       element.imageSize = 64;
       element.account = {
@@ -139,9 +140,7 @@
       sandbox = sinon.sandbox.create();
 
       stub('gr-avatar', {
-        _getConfig: () => {
-          return Promise.resolve({plugin: {has_avatars: true}});
-        },
+        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
       });
 
       element = fixture('basic');
@@ -176,9 +175,7 @@
       sandbox = sinon.sandbox.create();
 
       stub('gr-avatar', {
-        _getConfig: () => {
-          return Promise.resolve({});
-        },
+        _getConfig: () => Promise.resolve({}),
       });
 
       element = fixture('basic');
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index 87caf64..7cac2d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -21,6 +21,7 @@
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="/bower_components/paper-button/paper-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 
 <dom-module id="gr-button">
   <template strip-whitespace>
@@ -116,13 +117,6 @@
       :host([link][primary]) {
         --text-color: var(--primary-button-background-color);
       }
-      :host([secondary]) {
-        --background-color: var(--secondary-button-text-color);
-        --text-color: var(--secondary-button-background-color);
-      }
-      :host([link][secondary]) {
-        --text-color: var(--secondary-button-text-color);
-      }
 
       /* Keep below color definition for primary so that this takes precedence
         when disabled. */
@@ -167,6 +161,7 @@
       <slot></slot>
       <i class="downArrow"></i>
     </paper-button>
+    <gr-reporting id="reporting"></gr-reporting>
   </template>
   <script src="gr-button.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index afc6ba8..681717f 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -17,65 +17,93 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-button',
+  /**
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.TooltipMixin
+   * @extends Polymer.Element
+   */
+  class GrButton extends Polymer.mixinBehaviors( [
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.TooltipBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-button'; }
 
-    properties: {
-      tooltip: String,
-      downArrow: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      link: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        observer: '_disabledChanged',
-        reflectToAttribute: true,
-      },
-      noUppercase: {
-        type: Boolean,
-        value: false,
-      },
-      _enabledTabindex: {
-        type: String,
-        value: '0',
-      },
-    },
+    static get properties() {
+      return {
+        tooltip: String,
+        downArrow: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+        link: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        loading: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        disabled: {
+          type: Boolean,
+          observer: '_disabledChanged',
+          reflectToAttribute: true,
+        },
+        noUppercase: {
+          type: Boolean,
+          value: false,
+        },
+        _enabledTabindex: {
+          type: String,
+          value: '0',
+        },
+      };
+    }
 
-    listeners: {
-      click: '_handleAction',
-      keydown: '_handleKeydown',
-    },
+    static get observers() {
+      return [
+        '_computeDisabled(disabled, loading)',
+      ];
+    }
 
-    observers: [
-      '_computeDisabled(disabled, loading)',
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('click',
+          e => this._handleAction(e));
+      this.addEventListener('keydown',
+          e => this._handleKeydown(e));
+    }
 
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.TooltipBehavior,
-    ],
-
-    hostAttributes: {
-      role: 'button',
-      tabindex: '0',
-    },
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('role', 'button');
+      this._ensureAttribute('tabindex', '0');
+    }
 
     _handleAction(e) {
       if (this.disabled) {
         e.preventDefault();
         e.stopImmediatePropagation();
       }
-    },
+      let el = this.root;
+      let path = '';
+      while (el = el.parentNode || el.host) {
+        if (el.tagName && el.tagName.startsWith('GR-APP')) {
+          break;
+        }
+        if (el.tagName) {
+          const idString = el.id ? '#' + el.id : '';
+          path = el.tagName + idString + ' ' + path;
+        }
+      }
+      this.$.reporting.reportInteraction('button-click',
+          path.trim().toLowerCase());
+    }
 
     _disabledChanged(disabled) {
       if (disabled) {
@@ -83,11 +111,11 @@
       }
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
       this.updateStyles();
-    },
+    }
 
     _computeDisabled(disabled, loading) {
       return disabled || loading;
-    },
+    }
 
     _handleKeydown(e) {
       if (this.modifierPressed(e)) { return; }
@@ -98,6 +126,8 @@
         e.stopPropagation();
         this.click();
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrButton.is, GrButton);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index ef593c0..bcb560a 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-button</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index a83bc2b..001632f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -17,31 +17,35 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-change-star',
-
+  /** @extends Polymer.Element */
+  class GrChangeStar extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-change-star'; }
     /**
      * Fired when star state is toggled.
      *
      * @event toggle-star
      */
 
-    properties: {
+    static get properties() {
+      return {
       /** @type {?} */
-      change: {
-        type: Object,
-        notify: true,
-      },
-    },
+        change: {
+          type: Object,
+          notify: true,
+        },
+      };
+    }
 
     _computeStarClass(starred) {
       return starred ? 'active' : '';
-    },
+    }
 
     _computeStarIcon(starred) {
       // Hollow star is used to indicate inactive state.
       return `gr-icons:star${starred ? '' : '-border'}`;
-    },
+    }
 
     toggleStar() {
       const newVal = !this.change.starred;
@@ -51,6 +55,8 @@
         composed: true,
         detail: {change: this.change, starred: newVal},
       }));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeStar.is, GrChangeStar);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index 7ee22a7..60c504a 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-star</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
index e6f52c6..7052a6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -36,35 +36,40 @@
   const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
       'current reviewers (or anyone with "View Private Changes" permission).';
 
-  Polymer({
-    is: 'gr-change-status',
+  /** @extends Polymer.Element */
+  class GrChangeStatus extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-change-status'; }
 
-    properties: {
-      flat: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      status: {
-        type: String,
-        observer: '_updateChipDetails',
-      },
-      tooltipText: {
-        type: String,
-        value: '',
-      },
-    },
+    static get properties() {
+      return {
+        flat: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        status: {
+          type: String,
+          observer: '_updateChipDetails',
+        },
+        tooltipText: {
+          type: String,
+          value: '',
+        },
+      };
+    }
 
     _computeStatusString(status) {
       if (status === ChangeStates.WIP && !this.flat) {
         return 'Work in Progress';
       }
       return status;
-    },
+    }
 
     _toClassName(str) {
       return str.toLowerCase().replace(/\s/g, '-');
-    },
+    }
 
     _updateChipDetails(status, previousStatus) {
       if (previousStatus) {
@@ -86,6 +91,8 @@
           this.tooltipText = '';
           break;
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrChangeStatus.is, GrChangeStatus);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
index 421c6ab5..96a1840 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-status</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
index bbd7ddf..7bc93b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
@@ -16,6 +16,8 @@
 -->
 
 <link rel="import" href="/bower_components/polymer/polymer.html">
+<link rel="import" href="/bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../../behaviors/fire-behavior/fire-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -47,7 +49,7 @@
         display: block;
         margin: 0 4px 4px 4px;
         white-space: normal;
-        box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12);
+        box-shadow: var(--elevation-level-2);
         border-radius: var(--border-radius);
         /** This is required for firefox to continue the inheritance */
         -webkit-user-select: inherit;
@@ -58,6 +60,9 @@
       #container.unresolved {
         background-color: var(--unresolved-comment-background-color);
       }
+      #container.robotComment {
+        background-color: var(--robot-comment-background-color);
+      }
       #commentInfoContainer {
         border-top: 1px dotted var(--border-color);
         display: flex;
@@ -75,6 +80,18 @@
         margin-left: var(--spacing-m);
         font-style: italic;
       }
+      .invalidLineNumber {
+        padding: var(--spacing-m);
+      }
+      .invalidLineNumberText {
+        color: var(--error-text-color);
+      }
+      .invalidLineNumberIcon {
+        color: var(--error-text-color);
+        vertical-align: top;
+        margin-right: var(--spacing-s);
+      }
+
     </style>
     <template is="dom-if" if="[[showFilePath]]">
       <div class="pathInfo">
@@ -82,19 +99,24 @@
         <span class="descriptionText">Patchset [[patchNum]]</span>
       </div>
     </template>
-    <div id="container" class$="[[_computeHostClass(unresolved)]]">
+    <div id="container" class$="[[_computeHostClass(unresolved, isRobotComment)]]">
+      <template is="dom-if" if="[[invalidLineNumber]]">
+        <div class="invalidLineNumber">
+          <span class="invalidLineNumberText"><iron-icon icon="gr-icons:error" class="invalidLineNumberIcon"></iron-icon>This comment thread is attached to non-existing line [[lineNum]].</span>
+        </div>
+      </template>
       <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
           as="comment">
         <gr-comment
             comment="{{comment}}"
-            robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
+            comments="{{comments}}"
+            robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
             change-num="[[changeNum]]"
             patch-num="[[patchNum]]"
             draft="[[_isDraft(comment)]]"
             show-actions="[[_showActions]]"
             comment-side="[[comment.__commentSide]]"
             side="[[comment.side]]"
-            root-id="[[rootId]]"
             project-config="[[_projectConfig]]"
             on-create-fix-comment="_handleCommentFix"
             on-comment-discard="_handleCommentDiscard"
@@ -107,25 +129,21 @@
           <gr-button
               id="replyBtn"
               link
-              secondary
               class="action reply"
               on-click="_handleCommentReply">Reply</gr-button>
           <gr-button
               id="quoteBtn"
               link
-              secondary
               class="action quote"
               on-click="_handleCommentQuote">Quote</gr-button>
           <gr-button
               id="ackBtn"
               link
-              secondary
               class="action ack"
               on-click="_handleCommentAck">Ack</gr-button>
           <gr-button
               id="doneBtn"
               link
-              secondary
               class="action done"
               on-click="_handleCommentDone">Done</gr-button>
         </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index c220ecf..00ff03a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -20,9 +20,23 @@
   const UNRESOLVED_EXPAND_COUNT = 5;
   const NEWLINE_PATTERN = /\n/g;
 
-  Polymer({
-    is: 'gr-comment-thread',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @extends Polymer.Element
+   */
+  class GrCommentThread extends Polymer.mixinBehaviors( [
+    /**
+     * Not used in this element rather other elements tests
+     */
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+    Gerrit.PathListBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-comment-thread'; }
     /**
      * Fired when the thread should be discarded.
      *
@@ -55,103 +69,115 @@
      *     (start_line, start_character) is inclusive, and the end position
      *     (end_line, end_character) is exclusive.
      */
-    properties: {
-      changeNum: String,
-      comments: {
-        type: Array,
-        value() { return []; },
-      },
-      /**
-       * @type {?{start_line: number, start_character: number, end_line: number,
-       *          end_character: number}}
-       */
-      range: {
-        type: Object,
-        reflectToAttribute: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      commentSide: {
-        type: String,
-        reflectToAttribute: true,
-      },
-      patchNum: String,
-      path: String,
-      projectName: {
-        type: String,
-        observer: '_projectNameChanged',
-      },
-      hasDraft: {
-        type: Boolean,
-        notify: true,
-        reflectToAttribute: true,
-      },
-      isOnParent: {
-        type: Boolean,
-        value: false,
-      },
-      parentIndex: {
-        type: Number,
-        value: null,
-      },
-      rootId: {
-        type: String,
-        notify: true,
-        computed: '_computeRootId(comments.*)',
-      },
-      /**
-       * If this is true, the comment thread also needs to have the change and
-       * line properties property set
-       */
-      showFilePath: {
-        type: Boolean,
-        value: false,
-      },
-      /** Necessary only if showFilePath is true or when used with gr-diff */
-      lineNum: {
-        type: Number,
-        reflectToAttribute: true,
-      },
-      unresolved: {
-        type: Boolean,
-        notify: true,
-        reflectToAttribute: true,
-      },
-      _showActions: Boolean,
-      _lastComment: Object,
-      _orderedComments: Array,
-      _projectConfig: Object,
-    },
+    static get properties() {
+      return {
+        changeNum: String,
+        comments: {
+          type: Array,
+          value() { return []; },
+        },
+        /**
+         * @type {?{start_line: number, start_character: number, end_line: number,
+         *          end_character: number}}
+         */
+        range: {
+          type: Object,
+          reflectToAttribute: true,
+        },
+        keyEventTarget: {
+          type: Object,
+          value() { return document.body; },
+        },
+        commentSide: {
+          type: String,
+          reflectToAttribute: true,
+        },
+        patchNum: String,
+        path: String,
+        projectName: {
+          type: String,
+          observer: '_projectNameChanged',
+        },
+        hasDraft: {
+          type: Boolean,
+          notify: true,
+          reflectToAttribute: true,
+        },
+        isOnParent: {
+          type: Boolean,
+          value: false,
+        },
+        parentIndex: {
+          type: Number,
+          value: null,
+        },
+        rootId: {
+          type: String,
+          notify: true,
+          computed: '_computeRootId(comments.*)',
+        },
+        /**
+         * If this is true, the comment thread also needs to have the change and
+         * line properties property set
+         */
+        showFilePath: {
+          type: Boolean,
+          value: false,
+        },
+        /** It is possible to add comment to non-existing line via API */
+        invalidLineNumber: {
+          type: Number,
+          reflectToAttribute: true,
+        },
+        /** Necessary only if showFilePath is true or when used with gr-diff */
+        lineNum: {
+          type: Number,
+          reflectToAttribute: true,
+        },
+        unresolved: {
+          type: Boolean,
+          notify: true,
+          reflectToAttribute: true,
+        },
+        _showActions: Boolean,
+        _lastComment: Object,
+        _orderedComments: Array,
+        _projectConfig: Object,
+        isRobotComment: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+      };
+    }
 
-    behaviors: [
-      /**
-       * Not used in this element rather other elements tests
-       */
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-      Gerrit.PathListBehavior,
-    ],
+    static get observers() {
+      return [
+        '_commentsChanged(comments.*)',
+      ];
+    }
 
-    listeners: {
-      'comment-update': '_handleCommentUpdate',
-    },
+    get keyBindings() {
+      return {
+        'e shift+e': '_handleEKey',
+      };
+    }
 
-    observers: [
-      '_commentsChanged(comments.*)',
-    ],
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('comment-update',
+          e => this._handleCommentUpdate(e));
+    }
 
-    keyBindings: {
-      'e shift+e': '_handleEKey',
-    },
-
+    /** @override */
     attached() {
+      super.attached();
       this._getLoggedIn().then(loggedIn => {
         this._showActions = loggedIn;
       });
       this._setInitialExpandedState();
-    },
+    }
 
     addOrEditDraft(opt_lineNum, opt_range) {
       const lastComment = this.comments[this.comments.length - 1] || {};
@@ -169,56 +195,61 @@
         const unresolved = lastComment ? lastComment.unresolved : undefined;
         this.addDraft(opt_lineNum, range, unresolved);
       }
-    },
+    }
 
     addDraft(opt_lineNum, opt_range, opt_unresolved) {
       const draft = this._newDraft(opt_lineNum, opt_range);
       draft.__editing = true;
       draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
       this.push('comments', draft);
-    },
+    }
 
     fireRemoveSelf() {
       this.dispatchEvent(new CustomEvent('thread-discard',
           {detail: {rootId: this.rootId}, bubbles: false}));
-    },
+    }
 
     _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
       return Gerrit.Nav.getUrlForDiffById(changeNum,
           projectName, path, patchNum,
           null, this.lineNum);
-    },
+    }
 
     _computeDisplayPath(path) {
       const lineString = this.lineNum ? `#${this.lineNum}` : '';
       return this.computeDisplayPath(path) + lineString;
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _commentsChanged() {
       this._orderedComments = this._sortedComments(this.comments);
       this.updateThreadProperties();
-    },
+    }
 
     updateThreadProperties() {
       if (this._orderedComments.length) {
         this._lastComment = this._getLastComment();
         this.unresolved = this._lastComment.unresolved;
         this.hasDraft = this._lastComment.__draft;
+        this.isRobotComment = !!(this._lastComment.robot_id);
       }
-    },
+    }
+
+    _shouldDisableAction(_showActions, _lastComment) {
+      return !_showActions || !_lastComment || !!_lastComment.__draft;
+    }
 
     _hideActions(_showActions, _lastComment) {
-      return !_showActions || !_lastComment || !!_lastComment.__draft ||
+      return this._shouldDisableAction(_showActions, _lastComment) ||
         !!_lastComment.robot_id;
-    },
+    }
 
     _getLastComment() {
       return this._orderedComments[this._orderedComments.length - 1] || {};
-    },
+    }
 
     _handleEKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
@@ -231,7 +262,7 @@
         if (this.modifierPressed(e)) { return; }
         this._expandCollapseComments(false);
       }
-    },
+    }
 
     _expandCollapseComments(actionIsCollapse) {
       const comments =
@@ -239,7 +270,7 @@
       for (const comment of comments) {
         comment.collapsed = actionIsCollapse;
       }
-    },
+    }
 
     /**
      * Sets the initial state of the comment thread.
@@ -259,7 +290,7 @@
           comment.collapsed = !isRobotComment && resolvedThread;
         }
       }
-    },
+    }
 
     _sortedComments(comments) {
       return comments.slice().sort((c1, c2) => {
@@ -275,7 +306,7 @@
         // If same date, fall back to sorting by id.
         return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
       });
-    },
+    }
 
     _createReplyComment(parent, content, opt_isEditing,
         opt_unresolved) {
@@ -309,11 +340,11 @@
           commentEl.save();
         }, 1);
       }
-    },
+    }
 
     _isDraft(comment) {
       return !!comment.__draft;
-    },
+    }
 
     /**
      * @param {boolean=} opt_quote
@@ -326,33 +357,33 @@
         quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
       }
       this._createReplyComment(comment, quoteStr, true, comment.unresolved);
-    },
+    }
 
     _handleCommentReply(e) {
       this._processCommentReply();
-    },
+    }
 
     _handleCommentQuote(e) {
       this._processCommentReply(true);
-    },
+    }
 
     _handleCommentAck(e) {
       const comment = this._lastComment;
       this._createReplyComment(comment, 'Ack', false, false);
-    },
+    }
 
     _handleCommentDone(e) {
       const comment = this._lastComment;
       this._createReplyComment(comment, 'Done', false, false);
-    },
+    }
 
     _handleCommentFix(e) {
       const comment = e.detail.comment;
       const msg = comment.message;
       const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      const response = quoteStr + 'Please Fix';
+      const response = quoteStr + 'Please fix.';
       this._createReplyComment(comment, response, false, true);
-    },
+    }
 
     _commentElWithDraftID(id) {
       const els = Polymer.dom(this.root).querySelectorAll('gr-comment');
@@ -362,7 +393,7 @@
         }
       }
       return null;
-    },
+    }
 
     _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
         opt_range) {
@@ -376,7 +407,7 @@
         d.unresolved = opt_unresolved;
       }
       return d;
-    },
+    }
 
     /**
      * @param {number=} opt_lineNum
@@ -402,12 +433,12 @@
         d.parent = this.parentIndex;
       }
       return d;
-    },
+    }
 
     _getSide(isOnParent) {
       if (isOnParent) { return 'PARENT'; }
       return 'REVISION';
-    },
+    }
 
     _computeRootId(comments) {
       // Keep the root ID even if the comment was removed, so that notification
@@ -415,7 +446,7 @@
       if (!comments.base.length) { return this.rootId; }
       const rootComment = comments.base[0];
       return rootComment.id || rootComment.__draftID;
-    },
+    }
 
     _handleCommentDiscard(e) {
       const diffCommentEl = Polymer.dom(e).rootTarget;
@@ -445,13 +476,13 @@
               changeComment.message);
         }
       }
-    },
+    }
 
     _handleCommentSavedOrDiscarded(e) {
       this.dispatchEvent(new CustomEvent('thread-changed',
           {detail: {rootId: this.rootId, path: this.path},
             bubbles: false}));
-    },
+    }
 
     _handleCommentUpdate(e) {
       const comment = e.detail.comment;
@@ -467,7 +498,7 @@
       // observers, the this.set() call above will not cause a thread update in
       // some situations.
       this.updateThreadProperties();
-    },
+    }
 
     _indexOf(comment, arr) {
       for (let i = 0; i < arr.length; i++) {
@@ -478,11 +509,14 @@
         }
       }
       return -1;
-    },
+    }
 
     _computeHostClass(unresolved) {
+      if (this.isRobotComment) {
+        return 'robotComment';
+      }
       return unresolved ? 'unresolved' : '';
-    },
+    }
 
     /**
      * Load the project config when a project name has been provided.
@@ -494,6 +528,8 @@
       this.$.restAPI.getProjectConfig(name).then(config => {
         this._projectConfig = config;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCommentThread.is, GrCommentThread);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
index d3946e6..c8627ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment-thread</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -152,6 +152,24 @@
       assert.isTrue(addDraftStub.called);
     });
 
+    test('_shouldDisableAction', () => {
+      let showActions = true;
+      const lastComment = {};
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(
+          element._shouldDisableAction(showActions, robotComment), false);
+    });
+
     test('_hideActions', () => {
       let showActions = true;
       const lastComment = {};
@@ -262,9 +280,7 @@
       MockInteractions.tap(replyBtn);
       flushAsynchronousOperations();
 
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
+      const drafts = element._orderedComments.filter(c => c.__draft == true);
       assert.equal(drafts.length, 1);
       assert.notOk(drafts[0].message, 'message should be empty');
       assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
@@ -281,9 +297,7 @@
       MockInteractions.tap(quoteBtn);
       flushAsynchronousOperations();
 
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
+      const drafts = element._orderedComments.filter(c => c.__draft == true);
       assert.equal(drafts.length, 1);
       assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
       assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
@@ -312,9 +326,7 @@
       MockInteractions.tap(quoteBtn);
       flushAsynchronousOperations();
 
-      const drafts = element._orderedComments.filter(c => {
-        return c.__draft == true;
-      });
+      const drafts = element._orderedComments.filter(c => c.__draft == true);
       assert.equal(drafts.length, 1);
       assert.equal(drafts[0].message,
           '> is this a crossover episode!?\n> It might be!\n\n');
@@ -334,9 +346,7 @@
       const ackBtn = element.$.ackBtn;
       MockInteractions.tap(ackBtn);
       flush(() => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
+        const drafts = element.comments.filter(c => c.__draft == true);
         assert.equal(drafts.length, 1);
         assert.equal(drafts[0].message, 'Ack');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
@@ -357,9 +367,7 @@
       const doneBtn = element.$.doneBtn;
       MockInteractions.tap(doneBtn);
       flush(() => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
+        const drafts = element.comments.filter(c => c.__draft == true);
         assert.equal(drafts.length, 1);
         assert.equal(drafts[0].message, 'Done');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
@@ -397,12 +405,10 @@
       const commentEl = element.$$('gr-comment');
       assert.ok(commentEl);
       commentEl.addEventListener('create-fix-comment', () => {
-        const drafts = element._orderedComments.filter(c => {
-          return c.__draft == true;
-        });
+        const drafts = element._orderedComments.filter(c => c.__draft == true);
         assert.equal(drafts.length, 1);
         assert.equal(
-            drafts[0].message, '> is this a crossover episode!?\n\nPlease Fix');
+            drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
         assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
         assert.isTrue(drafts[0].unresolved);
         done();
@@ -428,9 +434,7 @@
           Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
       assert.ok(draftEl);
       draftEl.addEventListener('comment-discard', () => {
-        const drafts = element.comments.filter(c => {
-          return c.__draft == true;
-        });
+        const drafts = element.comments.filter(c => c.__draft == true);
         assert.equal(drafts.length, 0);
         assert.isTrue(saveOrDiscardStub.called);
         assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
@@ -489,9 +493,7 @@
       MockInteractions.tap(replyBtn);
       flushAsynchronousOperations();
 
-      const editing = element._orderedComments.filter(c => {
-        return c.__editing == true;
-      });
+      const editing = element._orderedComments.filter(c => c.__editing == true);
       assert.equal(editing.length, 1);
       assert.equal(!!editing[0].__otherEditing, false);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
index 8ad261c..afd0e59 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -43,10 +43,6 @@
         display: block;
         font-family: var(--font-family);
         padding: var(--spacing-m);
-        --iron-autogrow-textarea: {
-          box-sizing: border-box;
-          padding: 2px;
-        };
       }
       :host([disabled]) {
         pointer-events: none;
@@ -60,7 +56,7 @@
         display: none;
       }
       .header {
-        align-items: baseline;
+        align-items: center;
         cursor: pointer;
         display: flex;
         margin: calc(0px - var(--spacing-m)) calc(0px - var(--spacing-m)) 0 calc(0px - var(--spacing-m));
@@ -119,7 +115,6 @@
         --gr-button: {
           height: 20px;
           padding: 0 var(--spacing-s);
-          color: var(--default-button-text-color);
         }
       }
       .editMessage {
@@ -247,7 +242,7 @@
     <div id="container" class="container">
       <div class="header" id="header" on-click="_handleToggleCollapsed">
         <div class="headerLeft">
-          <span class="authorName">[[comment.author.name]]</span>
+          <span class="authorName">[[_computeAuthorName(comment)]]</span>
           <span class="draftLabel">DRAFT</span>
           <gr-tooltip-content class="draftTooltip"
               has-tooltip
@@ -258,13 +253,19 @@
         <div class="headerMiddle">
           <span class="collapsedContent">[[comment.message]]</span>
         </div>
+        <div hidden$="[[_computeHideRunDetails(comment, collapsed)]]" class="runIdMessage message">
+          <div class="runIdInformation">
+            <a class="robotRunLink" href$="[[comment.url]]">
+              <span class="robotRun link">Run Details</span>
+            </a>
+          </div>
+        </div>
         <gr-button
             id="deleteBtn"
             link
-            secondary
             class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
             on-click="_handleCommentDelete">
-          (Delete)
+          <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
         </gr-button>
         <span class="date" on-click="_handleAnchorClick">
           <gr-date-formatter
@@ -284,10 +285,9 @@
         </div>
       </div>
       <div class="body">
-        <template is="dom-if" if="[[comment.robot_id]]">
+        <template is="dom-if" if="[[isRobotComment]]">
           <div class="robotId" hidden$="[[collapsed]]">
-            <iron-icon class="robotIcon" icon="gr-icons:robot"></iron-icon>
-            [[comment.robot_id]]
+            [[comment.author.name]]
           </div>
         </template>
         <template is="dom-if" if="[[editing]]">
@@ -305,21 +305,7 @@
         <gr-formatted-text class="message"
             content="[[comment.message]]"
             no-trailing-margin="[[!comment.__draft]]"
-            collapsed="[[collapsed]]"
             config="[[projectConfig.commentlinks]]"></gr-formatted-text>
-        <div hidden$="[[!comment.robot_run_id]]" class="message">
-          <div class="runIdInformation" hidden$="[[collapsed]]">
-            Run ID:
-            <template is="dom-if" if="[[comment.url]]">
-              <a class="robotRunLink" href$="[[comment.url]]">
-                <span class="robotRun link">[[comment.robot_run_id]]</span>
-              </a>
-            </template>
-            <template is="dom-if" if="[[!comment.url]]">
-              <span class="robotRun text">[[comment.robot_run_id]]</span>
-            </template>
-          </div>
-        </div>
         <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
           <div class="action resolve hideOnPublished">
             <label>
@@ -333,22 +319,18 @@
           <div class="rightActions">
             <gr-button
                 link
-                secondary
                 class="action cancel hideOnPublished"
                 on-click="_handleCancel">Cancel</gr-button>
             <gr-button
                 link
-                secondary
                 class="action discard hideOnPublished"
                 on-click="_handleDiscard">Discard</gr-button>
             <gr-button
                 link
-                secondary
                 class="action edit hideOnPublished"
                 on-click="_handleEdit">Edit</gr-button>
             <gr-button
                 link
-                secondary
                 disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
                 class="action save hideOnPublished"
                 on-click="_handleSave">Save</gr-button>
@@ -356,13 +338,23 @@
         </div>
         <div class="robotActions" hidden$="[[!_showRobotActions]]">
           <template is="dom-if" if="[[isRobotComment]]">
+            <template is="dom-if" if="[[!_hasHumanReply]]">
+              <gr-button
+                  link
+                  class="action fix"
+                  on-click="_handleFix"
+                  disabled="[[robotButtonDisabled]]">
+                Please Fix
+              </gr-button>
+            </template>
             <gr-button
                 link
                 secondary
-                class="action fix"
-                on-click="_handleFix"
+                class="action show-fix"
+                hidden$="[[_hasNoFix(comment)]]"
+                on-click="_handleShowFix"
                 disabled="[[robotButtonDisabled]]">
-              Please Fix
+              Show Fix
             </gr-button>
             <gr-endpoint-decorator name="robot-comment-controls">
               <gr-endpoint-param name="comment" value="[[comment]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 6881929..3f4cc69 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -31,9 +31,18 @@
 
   const FILE = 'FILE';
 
-  Polymer({
-    is: 'gr-comment',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrComment extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-comment'; }
     /**
      * Fired when the create fix comment action is triggered.
      *
@@ -41,6 +50,12 @@
      */
 
     /**
+     * Fired when the show fix preview action is triggered.
+     *
+     * @event open-fix-preview
+     */
+
+    /**
      * Fired when this comment is discarded.
      *
      * @event comment-discard
@@ -64,108 +79,117 @@
      * @event comment-anchor-tap
      */
 
-    properties: {
-      changeNum: String,
-      /** @type {?} */
-      comment: {
-        type: Object,
-        notify: true,
-        observer: '_commentChanged',
-      },
-      isRobotComment: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: Boolean,
-        value: false,
-        observer: '_draftChanged',
-      },
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_editingChanged',
-      },
-      discarding: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      hasChildren: Boolean,
-      patchNum: String,
-      showActions: Boolean,
-      _showHumanActions: Boolean,
-      _showRobotActions: Boolean,
-      collapsed: {
-        type: Boolean,
-        value: true,
-        observer: '_toggleCollapseClass',
-      },
-      /** @type {?} */
-      projectConfig: Object,
-      robotButtonDisabled: Boolean,
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
+    static get properties() {
+      return {
+        changeNum: String,
+        /** @type {!Gerrit.Comment} */
+        comment: {
+          type: Object,
+          notify: true,
+          observer: '_commentChanged',
+        },
+        comments: {
+          type: Array,
+        },
+        isRobotComment: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        draft: {
+          type: Boolean,
+          value: false,
+          observer: '_draftChanged',
+        },
+        editing: {
+          type: Boolean,
+          value: false,
+          observer: '_editingChanged',
+        },
+        discarding: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        hasChildren: Boolean,
+        patchNum: String,
+        showActions: Boolean,
+        _showHumanActions: Boolean,
+        _showRobotActions: Boolean,
+        collapsed: {
+          type: Boolean,
+          value: true,
+          observer: '_toggleCollapseClass',
+        },
+        /** @type {?} */
+        projectConfig: Object,
+        robotButtonDisabled: Boolean,
+        _hasHumanReply: Boolean,
+        _isAdmin: {
+          type: Boolean,
+          value: false,
+        },
 
-      _xhrPromise: Object, // Used for testing.
-      _messageText: {
-        type: String,
-        value: '',
-        observer: '_messageTextChanged',
-      },
-      commentSide: String,
+        _xhrPromise: Object, // Used for testing.
+        _messageText: {
+          type: String,
+          value: '',
+          observer: '_messageTextChanged',
+        },
+        commentSide: String,
+        side: String,
 
-      resolved: Boolean,
+        resolved: Boolean,
 
-      _numPendingDraftRequests: {
-        type: Object,
-        value:
+        _numPendingDraftRequests: {
+          type: Object,
+          value:
             {number: 0}, // Intentional to share the object across instances.
-      },
+        },
 
-      _enableOverlay: {
-        type: Boolean,
-        value: false,
-      },
+        _enableOverlay: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * Property for storing references to overlay elements. When the overlays
-       * are moved to Gerrit.getRootElement() to be shown they are no-longer
-       * children, so they can't be queried along the tree, so they are stored
-       * here.
-       */
-      _overlays: {
-        type: Object,
-        value: () => ({}),
-      },
-    },
+        /**
+         * Property for storing references to overlay elements. When the overlays
+         * are moved to Gerrit.getRootElement() to be shown they are no-longer
+         * children, so they can't be queried along the tree, so they are stored
+         * here.
+         */
+        _overlays: {
+          type: Object,
+          value: () => { return {}; },
+        },
+      };
+    }
 
-    observers: [
-      '_commentMessageChanged(comment.message)',
-      '_loadLocalDraft(changeNum, patchNum, comment)',
-      '_isRobotComment(comment)',
-      '_calculateActionstoShow(showActions, isRobotComment)',
-    ],
+    static get observers() {
+      return [
+        '_commentMessageChanged(comment.message)',
+        '_loadLocalDraft(changeNum, patchNum, comment)',
+        '_isRobotComment(comment)',
+        '_calculateActionstoShow(showActions, isRobotComment)',
+        '_computeHasHumanReply(comment, comments.*)',
+      ];
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+    get keyBindings() {
+      return {
+        'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+        'esc': '_handleEsc',
+      };
+    }
 
-    keyBindings: {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      'esc': '_handleEsc',
-    },
-
+    /** @override */
     attached() {
+      super.attached();
       if (this.editing) {
         this.collapsed = false;
       } else if (this.comment) {
@@ -174,40 +198,44 @@
       this._getIsAdmin().then(isAdmin => {
         this._isAdmin = isAdmin;
       });
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.cancelDebouncer('fire-update');
       if (this.textarea) {
         this.textarea.closeDropdown();
       }
-    },
+    }
 
     get textarea() {
-      return this.$$('#editTextarea');
-    },
+      return this.shadowRoot.querySelector('#editTextarea');
+    }
 
     get confirmDeleteOverlay() {
       if (!this._overlays.confirmDelete) {
         this._enableOverlay = true;
         Polymer.dom.flush();
-        this._overlays.confirmDelete = this.$$('#confirmDeleteOverlay');
+        this._overlays.confirmDelete = this.shadowRoot
+            .querySelector('#confirmDeleteOverlay');
       }
       return this._overlays.confirmDelete;
-    },
+    }
 
     get confirmDiscardOverlay() {
       if (!this._overlays.confirmDiscard) {
         this._enableOverlay = true;
         Polymer.dom.flush();
-        this._overlays.confirmDiscard = this.$$('#confirmDiscardOverlay');
+        this._overlays.confirmDiscard = this.shadowRoot
+            .querySelector('#confirmDiscardOverlay');
       }
       return this._overlays.confirmDiscard;
-    },
+    }
 
     _computeShowHideIcon(collapsed) {
       return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
-    },
+    }
 
     _calculateActionstoShow(showActions, isRobotComment) {
       // Polymer 2: check for undefined
@@ -217,19 +245,19 @@
 
       this._showHumanActions = showActions && !isRobotComment;
       this._showRobotActions = showActions && isRobotComment;
-    },
+    }
 
     _isRobotComment(comment) {
       this.isRobotComment = !!comment.robot_id;
-    },
+    }
 
     isOnParent() {
       return this.side === 'PARENT';
-    },
+    }
 
     _getIsAdmin() {
       return this.$.restAPI.getIsAdmin();
-    },
+    }
 
     /**
      * @param {*=} opt_comment
@@ -266,13 +294,14 @@
           this._fireSave();
           return obj;
         });
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
+      })
+          .catch(err => {
+            this.disabled = false;
+            throw err;
+          });
 
       return this._xhrPromise;
-    },
+    }
 
     _eraseDraftComment() {
       // Prevents a race condition in which removing the draft comment occurs
@@ -286,7 +315,7 @@
         line: this.comment.line,
         range: this.comment.range,
       });
-    },
+    }
 
     _commentChanged(comment) {
       this.editing = !!comment.__editing;
@@ -294,7 +323,15 @@
       if (this.editing) { // It's a new draft/reply, notify.
         this._fireUpdate();
       }
-    },
+    }
+
+    _computeHasHumanReply() {
+      if (!this.comment || !this.comments) return;
+      // hide please fix button for robot comment that has human reply
+      this._hasHumanReply = this.comments
+          .some(c => c.in_reply_to && c.in_reply_to === this.comment.id &&
+            !c.robot_id);
+    }
 
     /**
      * @param {!Object=} opt_mixin
@@ -306,21 +343,21 @@
         comment: this.comment,
         patchNum: this.patchNum,
       });
-    },
+    }
 
     _fireSave() {
       this.fire('comment-save', this._getEventPayload());
-    },
+    }
 
     _fireUpdate() {
       this.debounce('fire-update', () => {
         this.fire('comment-update', this._getEventPayload());
       });
-    },
+    }
 
     _draftChanged(draft) {
       this.$.container.classList.toggle('draft', draft);
-    },
+    }
 
     _editingChanged(editing, previousValue) {
       // Polymer 2: observer fires when at least one property is defined.
@@ -342,14 +379,14 @@
       if (editing) {
         this.async(() => {
           Polymer.dom.flush();
-          this.textarea.putCursorAtEnd();
+          this.textarea && this.textarea.putCursorAtEnd();
         }, 1);
       }
-    },
+    }
 
     _computeDeleteButtonClass(isAdmin, draft) {
       return isAdmin && !draft ? 'showDeleteButtons' : '';
-    },
+    }
 
     _computeSaveDisabled(draft, comment, resolved) {
       // If resolved state has changed and a msg exists, save should be enabled.
@@ -357,7 +394,7 @@
         return false;
       }
       return !draft || draft.trim() === '';
-    },
+    }
 
     _handleSaveKey(e) {
       if (!this._computeSaveDisabled(this._messageText, this.comment,
@@ -365,18 +402,18 @@
         e.preventDefault();
         this._handleSave(e);
       }
-    },
+    }
 
     _handleEsc(e) {
       if (!this._messageText.length) {
         e.preventDefault();
         this._handleCancel(e);
       }
-    },
+    }
 
     _handleToggleCollapsed() {
       this.collapsed = !this.collapsed;
-    },
+    }
 
     _toggleCollapseClass(collapsed) {
       if (collapsed) {
@@ -384,11 +421,11 @@
       } else {
         this.$.container.classList.remove('collapsed');
       }
-    },
+    }
 
     _commentMessageChanged(message) {
       this._messageText = message || '';
-    },
+    }
 
     _messageTextChanged(newValue, oldValue) {
       if (!this.comment || (this.comment && this.comment.id)) {
@@ -413,7 +450,7 @@
           this.$.storage.setDraftComment(commentLocation, message);
         }
       }, STORAGE_DEBOUNCE_INTERVAL);
-    },
+    }
 
     _handleAnchorClick(e) {
       e.preventDefault();
@@ -428,14 +465,14 @@
           side: this.side,
         },
       }));
-    },
+    }
 
     _handleEdit(e) {
       e.preventDefault();
       this._messageText = this.comment.message;
       this.editing = true;
       this.$.reporting.recordDraftInteraction();
-    },
+    }
 
     _handleSave(e) {
       e.preventDefault();
@@ -449,7 +486,7 @@
       const timer = this.$.reporting.getTimer(timingLabel);
       this.set('comment.__editing', false);
       return this.save().then(() => { timer.end(); });
-    },
+    }
 
     _handleCancel(e) {
       e.preventDefault();
@@ -462,12 +499,12 @@
       }
       this._messageText = this.comment.message;
       this.editing = false;
-    },
+    }
 
     _fireDiscard() {
       this.cancelDebouncer('fire-update');
       this.fire('comment-discard', this._getEventPayload());
-    },
+    }
 
     _handleFix() {
       this.dispatchEvent(new CustomEvent('create-fix-comment', {
@@ -475,7 +512,19 @@
         composed: true,
         detail: this._getEventPayload(),
       }));
-    },
+    }
+
+    _handleShowFix() {
+      this.dispatchEvent(new CustomEvent('open-fix-preview', {
+        bubbles: true,
+        composed: true,
+        detail: this._getEventPayload(),
+      }));
+    }
+
+    _hasNoFix(comment) {
+      return !comment || !comment.fix_suggestions;
+    }
 
     _handleDiscard(e) {
       e.preventDefault();
@@ -490,14 +539,14 @@
         this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
             .resetFocus();
       });
-    },
+    }
 
     _handleConfirmDiscard(e) {
       e.preventDefault();
       const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
       this._closeConfirmDiscardOverlay();
       return this._discardDraft().then(() => { timer.end(); });
-    },
+    }
 
     _discardDraft() {
       if (!this.comment.__draft) {
@@ -522,17 +571,18 @@
         }
 
         this._fireDiscard();
-      }).catch(err => {
-        this.disabled = false;
-        throw err;
-      });
+      })
+          .catch(err => {
+            this.disabled = false;
+            throw err;
+          });
 
       return this._xhrPromise;
-    },
+    }
 
     _closeConfirmDiscardOverlay() {
       this._closeOverlay(this.confirmDiscardOverlay);
-    },
+    }
 
     _getSavingMessage(numPending) {
       if (numPending === 0) {
@@ -543,17 +593,17 @@
         numPending,
         numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
       ].join(' ');
-    },
+    }
 
     _showStartRequest() {
       const numPending = ++this._numPendingDraftRequests.number;
       this._updateRequestToast(numPending);
-    },
+    }
 
     _showEndRequest() {
       const numPending = --this._numPendingDraftRequests.number;
       this._updateRequestToast(numPending);
-    },
+    }
 
     _handleFailedDraftRequest() {
       this._numPendingDraftRequests.number--;
@@ -561,7 +611,7 @@
       // Cancel the debouncer so that error toasts from the error-manager will
       // not be overridden.
       this.cancelDebouncer('draft-toast');
-    },
+    }
 
     _updateRequestToast(numPending) {
       const message = this._getSavingMessage(numPending);
@@ -572,7 +622,7 @@
         document.body.dispatchEvent(new CustomEvent(
             'show-alert', {detail: {message}, bubbles: true, composed: true}));
       }, TOAST_DEBOUNCE_INTERVAL);
-    },
+    }
 
     _saveDraft(draft) {
       this._showStartRequest();
@@ -585,7 +635,7 @@
             }
             return result;
           });
-    },
+    }
 
     _deleteDraft(draft) {
       this._showStartRequest();
@@ -598,11 +648,11 @@
         }
         return result;
       });
-    },
+    }
 
     _getPatchNum() {
       return this.isOnParent() ? 'PARENT' : this.patchNum;
-    },
+    }
 
     _loadLocalDraft(changeNum, patchNum, comment) {
       // Polymer 2: check for undefined
@@ -631,7 +681,7 @@
       if (draft) {
         this.set('comment.message', draft.message);
       }
-    },
+    }
 
     _handleToggleResolved() {
       this.$.reporting.recordDraftInteraction();
@@ -645,25 +695,38 @@
         // Save the resolved state immediately.
         this.save(payload.comment);
       }
-    },
+    }
 
     _handleCommentDelete() {
       this._openOverlay(this.confirmDeleteOverlay);
-    },
+    }
 
     _handleCancelDeleteComment() {
       this._closeOverlay(this.confirmDeleteOverlay);
-    },
+    }
 
     _openOverlay(overlay) {
       Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
       return overlay.open();
-    },
+    }
+
+    _computeAuthorName(comment) {
+      if (!comment) return '';
+      if (comment.robot_id) {
+        return comment.robot_id;
+      }
+      return comment.author && comment.author.name;
+    }
+
+    _computeHideRunDetails(comment, collapsed) {
+      if (!comment) return true;
+      return !(comment.robot_id && comment.url && !collapsed);
+    }
 
     _closeOverlay(overlay) {
       Polymer.dom(Gerrit.getRootElement()).removeChild(overlay);
       overlay.close();
-    },
+    }
 
     _handleConfirmDeleteComment() {
       const dialog =
@@ -674,6 +737,8 @@
             this._handleCancelDeleteComment();
             this.comment = newComment;
           });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrComment.is, GrComment);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
index c829343..ae8f3f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-comment</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -44,7 +44,6 @@
 </test-fixture>
 
 <script>
-
   function isVisible(el) {
     assert.ok(el);
     return getComputedStyle(el).getPropertyValue('display') !== 'none';
@@ -457,17 +456,13 @@
       element.editing = false;
       element.collapsed = false;
       flushAsynchronousOperations();
-      assert.isNotOk(element.$$('.robotRun.link'));
-      assert.notEqual(getComputedStyle(element.$$('.robotRun.text')).display,
-          'none');
+      assert.isTrue(element.$$('.robotRun.link').textContent === 'Run Details');
 
       // A robot comment with run ID and url should display a link.
       element.set(['comment', 'url'], '/path/to/run');
       flushAsynchronousOperations();
       assert.notEqual(getComputedStyle(element.$$('.robotRun.link')).display,
           'none');
-      assert.equal(getComputedStyle(element.$$('.robotRun.text')).display,
-          'none');
     });
 
     test('collapsible drafts', () => {
@@ -528,6 +523,37 @@
           'header middle content is not visible');
     });
 
+    test('robot comment layout', () => {
+      const comment = Object.assign({
+        robot_id: 'happy_robot_id',
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+        },
+      }, element.comment);
+      element.comment = comment;
+      element.collapsed = false;
+      flushAsynchronousOperations();
+
+      let runIdMessage;
+      runIdMessage = element.$$('.runIdMessage');
+      assert.isFalse(runIdMessage.hidden);
+
+      const runDetailsLink = element.$$('.robotRunLink');
+      assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
+
+      const robotServiceName = element.$$('.authorName');
+      assert.isTrue(robotServiceName.textContent === 'happy_robot_id');
+
+      const authorName = element.$$('.robotId');
+      assert.isTrue(authorName.innerText === 'Happy Robot');
+
+      element.collapsed = true;
+      flushAsynchronousOperations();
+      runIdMessage = element.$$('.runIdMessage');
+      assert.isTrue(runIdMessage.hidden);
+    });
+
     test('draft creation/cancellation', done => {
       assert.isFalse(element.editing);
       MockInteractions.tap(element.$$('.edit'));
@@ -841,9 +867,157 @@
         done();
       });
       element.isRobotComment = true;
+      element.comments = [element.comment];
       flushAsynchronousOperations();
 
       MockInteractions.tap(element.$$('.fix'));
     });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+        {
+          __draft: true,
+          __draftID: '0.wbrfbwj89sa',
+          __date: '2019-12-04T13:41:03.689Z',
+          path: 'Documentation/config-gerrit.txt',
+          patchNum: 1,
+          side: 'REVISION',
+          __commentSide: 'right',
+          line: 10,
+          in_reply_to: 'eb0d03fd_5e95904f',
+          message: '> This is a robot comment with a fix.\n\nPlease fix.',
+          unresolved: true,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNull(element.$$('robotActions gr-button'));
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNotNull(element.$$('.robotActions gr-button'));
+    });
+
+    test('_handleShowFix fires open-fix-preview event', done => {
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.comment = {fix_suggestions: [{}]};
+      element.isRobotComment = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.$$('.show-fix'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
index 62ab307..e92bddb 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -36,6 +36,9 @@
         flex-direction: column;
         width: 100%;
       }
+      p {
+        margin-bottom: var(--spacing-l);
+      }
       label {
         cursor: pointer;
         display: block;
@@ -45,14 +48,7 @@
         font-family: var(--monospace-font-family);
         font-size: var(--font-size-mono);
         line-height: var(--line-height-mono);
-        padding: 0;
         width: 73ch; /* Add a char to account for the border. */
-
-        --iron-autogrow-textarea {
-          border: 1px solid var(--border-color);
-          box-sizing: border-box;
-          font-family: var(--monospace-font-family);
-        }
       }
     </style>
     <gr-dialog
@@ -61,6 +57,7 @@
         on-cancel="_handleCancelTap">
       <div class="header" slot="header">Delete Comment</div>
       <div class="main" slot="main">
+        <p>This is an admin function. Please only use in exceptional circumstances.</p>
         <label for="messageInput">Enter comment delete reason</label>
         <iron-autogrow-textarea
             id="messageInput"
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
index c2075f0..8d50fe0 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -17,9 +17,16 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-confirm-delete-comment-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrConfirmDeleteCommentDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-confirm-delete-comment-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -32,28 +39,29 @@
      * @event cancel
      */
 
-    properties: {
-      message: String,
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    static get properties() {
+      return {
+        message: String,
+      };
+    }
 
     resetFocus() {
       this.$.messageInput.textarea.focus();
-    },
+    }
 
     _handleConfirmTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('confirm', {reason: this.message}, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
-  });
+    }
+  }
+
+  customElements.define(GrConfirmDeleteCommentDialog.is,
+      GrConfirmDeleteCommentDialog);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index 3e87202..2ce03e3 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -19,34 +19,39 @@
 
   const COPY_TIMEOUT_MS = 1000;
 
-  Polymer({
-    is: 'gr-copy-clipboard',
+  /** @extends Polymer.Element */
+  class GrCopyClipboard extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-copy-clipboard'; }
 
-    properties: {
-      text: String,
-      buttonTitle: String,
-      hasTooltip: {
-        type: Boolean,
-        value: false,
-      },
-      hideInput: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        text: String,
+        buttonTitle: String,
+        hasTooltip: {
+          type: Boolean,
+          value: false,
+        },
+        hideInput: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
     focusOnCopy() {
       this.$.button.focus();
-    },
+    }
 
     _computeInputClass(hideInput) {
       return hideInput ? 'hideInput' : '';
-    },
+    }
 
     _handleInputClick(e) {
       e.preventDefault();
       Polymer.dom(e).rootTarget.select();
-    },
+    }
 
     _copyToClipboard() {
       if (this.hideInput) {
@@ -62,6 +67,8 @@
       this.async(
           () => this.$.icon.icon = 'gr-icons:content-copy',
           COPY_TIMEOUT_MS);
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCopyClipboard.is, GrCopyClipboard);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
index 9cec20e..14490f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-copy-clipboard</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
index d061ac2..5022766 100644
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-count-string-formatter</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index b97726e..fd70fd9 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -22,74 +22,81 @@
     KEEP_VISIBLE: 'keep-visible',
   };
 
-  Polymer({
-    is: 'gr-cursor-manager',
+  /** @extends Polymer.Element */
+  class GrCursorManager extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-cursor-manager'; }
 
-    properties: {
-      stops: {
-        type: Array,
-        value() {
-          return [];
+    static get properties() {
+      return {
+        stops: {
+          type: Array,
+          value() {
+            return [];
+          },
+          observer: '_updateIndex',
         },
-        observer: '_updateIndex',
-      },
-      /**
-       * @type {?Object}
-       */
-      target: {
-        type: Object,
-        notify: true,
-        observer: '_scrollToTarget',
-      },
-      /**
-       * The height of content intended to be included with the target.
-       *
-       * @type {?number}
-       */
-      _targetHeight: Number,
+        /**
+         * @type {?Object}
+         */
+        target: {
+          type: Object,
+          notify: true,
+          observer: '_scrollToTarget',
+        },
+        /**
+         * The height of content intended to be included with the target.
+         *
+         * @type {?number}
+         */
+        _targetHeight: Number,
 
-      /**
-       * The index of the current target (if any). -1 otherwise.
-       */
-      index: {
-        type: Number,
-        value: -1,
-        notify: true,
-      },
+        /**
+         * The index of the current target (if any). -1 otherwise.
+         */
+        index: {
+          type: Number,
+          value: -1,
+          notify: true,
+        },
 
-      /**
-       * The class to apply to the current target. Use null for no class.
-       */
-      cursorTargetClass: {
-        type: String,
-        value: null,
-      },
+        /**
+         * The class to apply to the current target. Use null for no class.
+         */
+        cursorTargetClass: {
+          type: String,
+          value: null,
+        },
 
-      /**
-       * The scroll behavior for the cursor. Values are 'never' and
-       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
-       * the viewport.
-       * TODO (beckysiegel) figure out why it can be undefined
-       *
-       * @type {string|undefined}
-       */
-      scrollBehavior: {
-        type: String,
-        value: ScrollBehavior.NEVER,
-      },
+        /**
+         * The scroll behavior for the cursor. Values are 'never' and
+         * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+         * the viewport.
+         * TODO (beckysiegel) figure out why it can be undefined
+         *
+         * @type {string|undefined}
+         */
+        scrollBehavior: {
+          type: String,
+          value: ScrollBehavior.NEVER,
+        },
 
-      /**
-       * When true, will call element.focus() during scrolling.
-       */
-      focusOnMove: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        /**
+         * When true, will call element.focus() during scrolling.
+         */
+        focusOnMove: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unsetCursor();
-    },
+    }
 
     /**
      * Move the cursor forward. Clipped to the ends of the stop list.
@@ -107,11 +114,11 @@
 
     next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
       this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
-    },
+    }
 
     previous(opt_condition) {
       this._moveCursor(-1, opt_condition);
-    },
+    }
 
     /**
      * Set the cursor to an arbitrary element.
@@ -133,32 +140,38 @@
       this._decorateTarget();
 
       if (opt_noScroll) { this.scrollBehavior = behavior; }
-    },
+    }
 
     unsetCursor() {
       this._unDecorateTarget();
       this.index = -1;
       this.target = null;
       this._targetHeight = null;
-    },
+    }
 
     isAtStart() {
       return this.index === 0;
-    },
+    }
 
     isAtEnd() {
       return this.index === this.stops.length - 1;
-    },
+    }
 
     moveToStart() {
       if (this.stops.length) {
         this.setCursor(this.stops[0]);
       }
-    },
+    }
+
+    moveToEnd() {
+      if (this.stops.length) {
+        this.setCursor(this.stops[this.stops.length - 1]);
+      }
+    }
 
     setCursorAtIndex(index, opt_noScroll) {
       this.setCursor(this.stops[index], opt_noScroll);
-    },
+    }
 
     /**
      * Move the cursor forward or backward by delta. Clipped to the beginning or
@@ -204,19 +217,19 @@
       if (this.focusOnMove) { this.target.focus(); }
 
       this._decorateTarget();
-    },
+    }
 
     _decorateTarget() {
       if (this.target && this.cursorTargetClass) {
         this.target.classList.add(this.cursorTargetClass);
       }
-    },
+    }
 
     _unDecorateTarget() {
       if (this.target && this.cursorTargetClass) {
         this.target.classList.remove(this.cursorTargetClass);
       }
-    },
+    }
 
     /**
      * Get the next stop index indicated by the delta direction.
@@ -253,7 +266,7 @@
       }
 
       return newIndex;
-    },
+    }
 
     _updateIndex() {
       if (!this.target) {
@@ -267,7 +280,7 @@
       } else {
         this.index = newIndex;
       }
-    },
+    }
 
     /**
      * Calculate where the element is relative to the window.
@@ -283,7 +296,7 @@
         top += offsetParent.offsetTop;
       }
       return top;
-    },
+    }
 
     /**
      * @return {boolean}
@@ -293,12 +306,12 @@
       return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
           top > dims.pageYOffset &&
           top < dims.pageYOffset + dims.innerHeight;
-    },
+    }
 
     _calculateScrollToValue(top, target) {
       const dims = this._getWindowDims();
       return top - (dims.innerHeight / 3) + (target.offsetHeight / 2);
-    },
+    }
 
     _scrollToTarget() {
       if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
@@ -326,7 +339,7 @@
       // element appears to be below the center of the window even when it
       // isn't.
       window.scrollTo(dims.scrollX, scrollToValue);
-    },
+    }
 
     _getWindowDims() {
       return {
@@ -335,6 +348,8 @@
         innerHeight: window.innerHeight,
         pageYOffset: window.pageYOffset,
       };
-    },
-  });
+    }
+  }
+
+  customElements.define(GrCursorManager.is, GrCursorManager);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index 0793ccd..103b41a 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-cursor-manager</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -129,7 +129,6 @@
       assert.equal(element.index, -1);
     });
 
-
     test('_moveCursor', () => {
       // Initialize the cursor with its stops.
       element.stops = list.querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 5b1ef7f..8c247e3 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -31,50 +31,58 @@
     MONTH_DAY_YEAR: 'MMM DD, YYYY', // Aug 29, 1997
   };
 
-  Polymer({
-    is: 'gr-date-formatter',
+  /**
+   * @appliesMixin Gerrit.TooltipMixin
+   * @extends Polymer.Element
+   */
+  class GrDateFormatter extends Polymer.mixinBehaviors( [
+    Gerrit.TooltipBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-date-formatter'; }
 
-    properties: {
-      dateStr: {
-        type: String,
-        value: null,
-        notify: true,
-      },
-      showDateAndTime: {
-        type: Boolean,
-        value: false,
-      },
+    static get properties() {
+      return {
+        dateStr: {
+          type: String,
+          value: null,
+          notify: true,
+        },
+        showDateAndTime: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * When true, the detailed date appears in a GR-TOOLTIP rather than in the
-       * native browser tooltip.
-       */
-      hasTooltip: Boolean,
+        /**
+         * When true, the detailed date appears in a GR-TOOLTIP rather than in the
+         * native browser tooltip.
+         */
+        hasTooltip: Boolean,
 
-      /**
-       * The title to be used as the native tooltip or by the tooltip behavior.
-       */
-      title: {
-        type: String,
-        reflectToAttribute: true,
-        computed: '_computeFullDateStr(dateStr, _timeFormat)',
-      },
+        /**
+         * The title to be used as the native tooltip or by the tooltip behavior.
+         */
+        title: {
+          type: String,
+          reflectToAttribute: true,
+          computed: '_computeFullDateStr(dateStr, _timeFormat)',
+        },
 
-      _timeFormat: String, // No default value to prevent flickering.
-      _relative: Boolean, // No default value to prevent flickering.
-    },
+        _timeFormat: String, // No default value to prevent flickering.
+        _relative: Boolean, // No default value to prevent flickering.
+      };
+    }
 
-    behaviors: [
-      Gerrit.TooltipBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._loadPreferences();
-    },
+    }
 
     _getUtcOffsetString() {
       return ' UTC' + moment().format('Z');
-    },
+    }
 
     _loadPreferences() {
       return this._getLoggedIn().then(loggedIn => {
@@ -88,7 +96,7 @@
           this._loadRelative(),
         ]);
       });
-    },
+    }
 
     _loadTimeFormat() {
       return this._getPreferences().then(preferences => {
@@ -104,22 +112,22 @@
             throw Error('Invalid time format: ' + timeFormat);
         }
       });
-    },
+    }
 
     _loadRelative() {
       return this._getPreferences().then(prefs => {
         // prefs.relative_date_in_change_table is not set when false.
         this._relative = !!(prefs && prefs.relative_date_in_change_table);
       });
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _getPreferences() {
       return this.$.restAPI.getPreferences();
-    },
+    }
 
     /**
      * Return true if date is within 24 hours and on the same day.
@@ -127,7 +135,7 @@
     _isWithinDay(now, date) {
       const diff = -date.diff(now);
       return diff < Duration.DAY && date.day() === now.getDay();
-    },
+    }
 
     /**
      * Returns true if date is from one to six months.
@@ -136,7 +144,7 @@
       const diff = -date.diff(now);
       return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
           diff < 180 * Duration.DAY;
-    },
+    }
 
     _computeDateStr(dateStr, timeFormat, relative, showDateAndTime) {
       if (!dateStr) { return ''; }
@@ -163,13 +171,13 @@
         }
       }
       return date.format(format);
-    },
+    }
 
     _timeToSecondsFormat(timeFormat) {
       return timeFormat === TimeFormats.TIME_12 ?
         TimeFormats.TIME_12_WITH_SEC :
         TimeFormats.TIME_24_WITH_SEC;
-    },
+    }
 
     _computeFullDateStr(dateStr, timeFormat) {
       // Polymer 2: check for undefined
@@ -186,6 +194,8 @@
       let format = TimeFormats.MONTH_DAY_YEAR + ', ';
       format += this._timeToSecondsFormat(timeFormat);
       return date.format(format) + this._getUtcOffsetString();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDateFormatter.is, GrDateFormatter);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index 99af4e6..fe2f110 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-date-formatter</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -62,7 +62,9 @@
         expectedTooltip, done) {
       // Normalize and convert the date to mimic server response.
       dateStr = normalizedDate(dateStr)
-          .toJSON().replace('T', ' ').slice(0, -1);
+          .toJSON()
+          .replace('T', ' ')
+          .slice(0, -1);
       sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
       element.dateStr = dateStr;
       flush(() => {
@@ -87,15 +89,13 @@
     }
 
     suite('24 hours time format preference', () => {
-      setup(() => {
-        return stubRestAPI(
-            {time_format: 'HHMM_24', relative_date_in_change_table: false}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        });
-      });
+      setup(() => stubRestAPI(
+          {time_format: 'HHMM_24', relative_date_in_change_table: false}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
 
       test('invalid dates are quietly rejected', () => {
         assert.notOk((new Date('foo')).valueOf());
@@ -136,16 +136,16 @@
     });
 
     suite('12 hours time format preference', () => {
-      setup(() => {
-        // relative_date_in_change_table is not set when false.
-        return stubRestAPI(
+      setup(() =>
+      // relative_date_in_change_table is not set when false.
+        stubRestAPI(
             {time_format: 'HHMM_12'}
         ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
-        });
-      });
+        })
+      );
 
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
@@ -157,15 +157,13 @@
     });
 
     suite('relative date preference', () => {
-      setup(() => {
-        return stubRestAPI(
-            {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(() => {
-          element = fixture('basic');
-          sandbox.stub(element, '_getUtcOffsetString').returns('');
-          return element._loadPreferences();
-        });
-      });
+      setup(() => stubRestAPI(
+          {time_format: 'HHMM_12', relative_date_in_change_table: true}
+      ).then(() => {
+        element = fixture('basic');
+        sandbox.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      }));
 
       test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
@@ -185,14 +183,12 @@
     });
 
     suite('logged in', () => {
-      setup(() => {
-        return stubRestAPI(
-            {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(() => {
-          element = fixture('basic');
-          return element._loadPreferences();
-        });
-      });
+      setup(() => stubRestAPI(
+          {time_format: 'HHMM_12', relative_date_in_change_table: true}
+      ).then(() => {
+        element = fixture('basic');
+        return element._loadPreferences();
+      }));
 
       test('Preferences are respected', () => {
         assert.equal(element._timeFormat, 'h:mm A');
@@ -201,12 +197,10 @@
     });
 
     suite('logged out', () => {
-      setup(() => {
-        return stubRestAPI(null).then(() => {
-          element = fixture('basic');
-          return element._loadPreferences();
-        });
-      });
+      setup(() => stubRestAPI(null).then(() => {
+        element = fixture('basic');
+        return element._loadPreferences();
+      }));
 
       test('Default preferences are respected', () => {
         assert.equal(element._timeFormat, 'HH:mm');
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
index 2ef5539..77418df 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.html
@@ -32,38 +32,35 @@
         display: flex;
         flex-direction: column;
         max-height: 90vh;
+        padding: var(--spacing-xl);
       }
       header {
-        border-bottom: 1px solid var(--border-color);
         flex-shrink: 0;
-        font-weight: var(--font-weight-bold);
+        padding-bottom: var(--spacing-xl);
       }
       main {
         display: flex;
         flex-shrink: 1;
         width: 100%;
       }
-      header,
-      main,
-      footer {
-        padding: var(--spacing-m) var(--spacing-xl);
-      }
-      gr-button {
-        margin-left: var(--spacing-l);
-      }
       footer {
         display: flex;
         flex-shrink: 0;
         justify-content: flex-end;
+        padding-top: var(--spacing-xl);
+      }
+      gr-button {
+        margin-left: var(--spacing-l);
       }
       .hidden {
         display: none;
       }
     </style>
     <div class="container" on-keydown="_handleKeydown">
-      <header><slot name="header"></slot></header>
+      <header class="font-h3"><slot name="header"></slot></header>
       <main><slot name="main"></slot></main>
       <footer>
+        <slot name="footer"></slot>
         <gr-button id="cancel" class$="[[_computeCancelClass(cancelLabel)]]" link on-click="_handleCancelTap">
           [[cancelLabel]]
         </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
index 68dc537..8d00452 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -17,9 +17,16 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-dialog',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrDialog extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-dialog'; }
     /**
      * Fired when the confirm button is pressed.
      *
@@ -32,33 +39,33 @@
      * @event cancel
      */
 
-    properties: {
-      confirmLabel: {
-        type: String,
-        value: 'Confirm',
-      },
-      // Supplying an empty cancel label will hide the button completely.
-      cancelLabel: {
-        type: String,
-        value: 'Cancel',
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-      },
-      confirmOnEnter: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        confirmLabel: {
+          type: String,
+          value: 'Confirm',
+        },
+        // Supplying an empty cancel label will hide the button completely.
+        cancelLabel: {
+          type: String,
+          value: 'Cancel',
+        },
+        disabled: {
+          type: Boolean,
+          value: false,
+        },
+        confirmOnEnter: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    hostAttributes: {
-      role: 'dialog',
-    },
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('role', 'dialog');
+    }
 
     _handleConfirm(e) {
       if (this.disabled) { return; }
@@ -66,24 +73,26 @@
       e.preventDefault();
       e.stopPropagation();
       this.fire('confirm', null, {bubbles: false});
-    },
+    }
 
     _handleCancelTap(e) {
       e.preventDefault();
       e.stopPropagation();
       this.fire('cancel', null, {bubbles: false});
-    },
+    }
 
     _handleKeydown(e) {
       if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
-    },
+    }
 
     resetFocus() {
       this.$.confirm.focus();
-    },
+    }
 
     _computeCancelClass(cancelLabel) {
       return cancelLabel.length ? '' : 'hidden';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDialog.is, GrDialog);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
index 1456e77..ced925e 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dialog</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
index 9d85d44..367e30c 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.html
@@ -24,8 +24,12 @@
 
 <dom-module id="gr-diff-preferences">
   <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style include="gr-form-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <div id="diffPreferences" class="gr-form-styles">
       <section>
         <span class="title">Context</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
index 36fdf5b..c408e5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
@@ -17,62 +17,69 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-diff-preferences',
+  /** @extends Polymer.Element */
+  class GrDiffPreferences extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-diff-preferences'; }
 
-    properties: {
-      hasUnsavedChanges: {
-        type: Boolean,
-        notify: true,
-        value: false,
-      },
+    static get properties() {
+      return {
+        hasUnsavedChanges: {
+          type: Boolean,
+          notify: true,
+          value: false,
+        },
 
-      /** @type {?} */
-      diffPrefs: Object,
-    },
+        /** @type {?} */
+        diffPrefs: Object,
+      };
+    }
 
     loadData() {
       return this.$.restAPI.getDiffPreferences().then(prefs => {
         this.diffPrefs = prefs;
       });
-    },
+    }
 
     _handleDiffPrefsChanged() {
       this.hasUnsavedChanges = true;
-    },
+    }
 
     _handleLineWrappingTap() {
       this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
       this._handleDiffPrefsChanged();
-    },
+    }
 
     _handleShowTabsTap() {
       this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
       this._handleDiffPrefsChanged();
-    },
+    }
 
     _handleShowTrailingWhitespaceTap() {
       this.set('diffPrefs.show_whitespace_errors',
           this.$.showTrailingWhitespaceInput.checked);
       this._handleDiffPrefsChanged();
-    },
+    }
 
     _handleSyntaxHighlightTap() {
       this.set('diffPrefs.syntax_highlighting',
           this.$.syntaxHighlightInput.checked);
       this._handleDiffPrefsChanged();
-    },
+    }
 
     _handleAutomaticReviewTap() {
       this.set('diffPrefs.manual_review',
           !this.$.automaticReviewInput.checked);
       this._handleDiffPrefsChanged();
-    },
+    }
 
     save() {
       return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
         this.hasUnsavedChanges = false;
       });
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDiffPreferences.is, GrDiffPreferences);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
index 4d2a1a4..fa1ade5 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-preferences</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index 121aa35..17fafce 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -17,40 +17,48 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-download-commands',
+  /**
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrDownloadCommands extends Polymer.mixinBehaviors( [
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-download-commands'; }
 
-    properties: {
-      commands: Array,
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      schemes: Array,
-      selectedScheme: {
-        type: String,
-        notify: true,
-      },
-    },
+    static get properties() {
+      return {
+        commands: Array,
+        _loggedIn: {
+          type: Boolean,
+          value: false,
+          observer: '_loggedInChanged',
+        },
+        schemes: Array,
+        selectedScheme: {
+          type: String,
+          notify: true,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.RESTClientBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
       });
-    },
+    }
 
     focusOnCopy() {
       this.$$('gr-shell-command').focusOnCopy();
-    },
+    }
 
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
-    },
+    }
 
     _loggedInChanged(loggedIn) {
       if (!loggedIn) { return; }
@@ -60,7 +68,7 @@
           this.selectedScheme = prefs.download_scheme.toLowerCase();
         }
       });
-    },
+    }
 
     _handleTabChange(e) {
       const scheme = this.schemes[e.detail.value];
@@ -71,15 +79,17 @@
               {download_scheme: this.selectedScheme});
         }
       }
-    },
+    }
 
     _computeSelected(schemes, selectedScheme) {
-      return (schemes.findIndex(scheme => scheme === selectedScheme) || 0)
-          + '';
-    },
+      return (schemes.findIndex(scheme => scheme === selectedScheme) || 0) +
+          '';
+    }
 
     _computeShowTabs(schemes) {
       return schemes.length > 1 ? '' : 'hidden';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDownloadCommands.is, GrDownloadCommands);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
index 85a5b1f..4d2bda7 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-download-commands</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -131,7 +131,7 @@
       test('saves scheme to preferences', () => {
         element._loggedIn = true;
         const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
-            () => { return Promise.resolve(); });
+            () => Promise.resolve());
 
         flushAsynchronousOperations();
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 98d7bf6..f7349eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -44,7 +44,7 @@
       }
       .dropdown-content {
         background-color: var(--dropdown-background-color);
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+        box-shadow: var(--elevation-level-2);
         max-height: 70vh;
         margin-top: var(--spacing-xxl);
         min-width: 266px;
@@ -59,16 +59,19 @@
         cursor: pointer;
         flex-direction: column;
         font-size: inherit;
+        // This variable was introduced in Dec 2019. We keep both min-height
+        // rules around, because --paper-item-min-height is not yet upstreamed.
+        --paper-item-min-height: 0;
         --paper-item: {
           min-height: 0;
           padding: 10px 16px;
-        }
+        };
         --paper-item-focused-before: {
           background-color: var(--selection-background-color);
-        }
+        };
         --paper-item-focused: {
           background-color: var(--selection-background-color);
-        }
+        };
       }
       paper-item:hover {
         background-color: var(--hover-background-color);
@@ -142,7 +145,7 @@
       <paper-listbox
           class="dropdown-content"
           slot="dropdown-content"
-          attr-for-selected="value"
+          attr-for-selected="data-value"
           selected="{{value}}"
           on-tap="_handleDropdownTap">
         <template is="dom-repeat"
@@ -150,7 +153,7 @@
             initial-count="[[initialCount]]">
           <paper-item
               disabled="[[item.disabled]]"
-              value="[[item.value]]">
+              data-value$="[[item.value]]">
             <div class="topContent">
               <div>[[item.text]]</div>
               <template is="dom-if" if="[[item.date]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
index efd1d0c..06b4a72 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -45,9 +45,11 @@
    */
   Defs.item;
 
-  Polymer({
-    is: 'gr-dropdown-list',
-
+  /** @extends Polymer.Element */
+  class GrDropdownList extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-dropdown-list'; }
     /**
      * Fired when the selected value changes
      *
@@ -56,24 +58,28 @@
      * @property {string|number} value
      */
 
-    properties: {
-      initialCount: Number,
-      /** @type {!Array<!Defs.item>} */
-      items: Object,
-      text: String,
-      disabled: {
-        type: Boolean,
-        value: false,
-      },
-      value: {
-        type: String,
-        notify: true,
-      },
-    },
+    static get properties() {
+      return {
+        initialCount: Number,
+        /** @type {!Array<!Defs.item>} */
+        items: Object,
+        text: String,
+        disabled: {
+          type: Boolean,
+          value: false,
+        },
+        value: {
+          type: String,
+          notify: true,
+        },
+      };
+    }
 
-    observers: [
-      '_handleValueChange(value, items)',
-    ],
+    static get observers() {
+      return [
+        '_handleValueChange(value, items)',
+      ];
+    }
 
     /**
      * Handle a click on the iron-dropdown element.
@@ -86,7 +92,7 @@
       this.async(() => {
         this.$.dropdown.close();
       }, 1);
-    },
+    }
 
     /**
      * Handle a click on the button to open the dropdown.
@@ -95,18 +101,18 @@
      */
     _showDropdownTapHandler(e) {
       this._open();
-    },
+    }
 
     /**
      * Open the dropdown.
      */
     _open() {
       this.$.dropdown.open();
-    },
+    }
 
     _computeMobileText(item) {
       return item.mobileText ? item.mobileText : item.text;
-    },
+    }
 
     _handleValueChange(value, items) {
       // Polymer 2: check for undefined
@@ -115,9 +121,7 @@
       }
 
       if (!value) { return; }
-      const selectedObj = items.find(item => {
-        return item.value + '' === value + '';
-      });
+      const selectedObj = items.find(item => item.value + '' === value + '');
       if (!selectedObj) { return; }
       this.text = selectedObj.triggerText? selectedObj.triggerText :
         selectedObj.text;
@@ -125,6 +129,8 @@
         detail: {value},
         bubbles: false,
       }));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDropdownList.is, GrDropdownList);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
index 2b63d99..8d098df 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown-list</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -109,7 +109,7 @@
 
         assert.isNotOk(Polymer.dom(items[0]).querySelector('gr-date-formatter'));
         assert.isNotOk(Polymer.dom(items[0]).querySelector('.bottomContent'));
-        assert.equal(items[0].value, element.items[0].value);
+        assert.equal(items[0].dataset.value, element.items[0].value);
         assert.equal(mobileItems[0].value, element.items[0].value);
         assert.equal(Polymer.dom(items[0]).querySelector('.topContent div')
             .innerText, element.items[0].text);
@@ -117,7 +117,6 @@
         // Since no mobile specific text, it should fall back to text.
         assert.equal(mobileItems[0].text, element.items[0].text);
 
-
         // Second Item
         // The second item should have top text, bottom text, and no date.
         assert.isFalse(!!items[1].disabled);
@@ -127,7 +126,7 @@
 
         assert.isNotOk(Polymer.dom(items[1]).querySelector('gr-date-formatter'));
         assert.isOk(Polymer.dom(items[1]).querySelector('.bottomContent'));
-        assert.equal(items[1].value, element.items[1].value);
+        assert.equal(items[1].dataset.value, element.items[1].value);
         assert.equal(mobileItems[1].value, element.items[1].value);
         assert.equal(Polymer.dom(items[1]).querySelector('.topContent div')
             .innerText, element.items[1].text);
@@ -148,7 +147,7 @@
 
         assert.isOk(Polymer.dom(items[2]).querySelector('gr-date-formatter'));
         assert.isOk(Polymer.dom(items[2]).querySelector('.bottomContent'));
-        assert.equal(items[2].value, element.items[2].value);
+        assert.equal(items[2].dataset.value, element.items[2].value);
         assert.equal(mobileItems[2].value, element.items[2].value);
         assert.equal(Polymer.dom(items[2]).querySelector('.topContent div')
             .innerText, element.items[2].text);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index d76721f..5d28390 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -37,7 +37,7 @@
       }
       .dropdown-content {
         background-color: var(--dropdown-background-color);
-        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+        box-shadow: var(--elevation-level-2);
       }
       gr-button {
         @apply --gr-button;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 11825c5..531f2e3 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -20,9 +20,18 @@
   const REL_NOOPENER = 'noopener';
   const REL_EXTERNAL = 'external';
 
-  Polymer({
-    is: 'gr-dropdown',
-
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrDropdown extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-dropdown'; }
     /**
      * Fired when a non-link dropdown item with the given ID is tapped.
      *
@@ -35,60 +44,59 @@
      * @event tap-item
      */
 
-    properties: {
-      items: {
-        type: Array,
-        observer: '_resetCursorStops',
-      },
-      downArrow: Boolean,
-      topContent: Object,
-      horizontalAlign: {
-        type: String,
-        value: 'left',
-      },
+    static get properties() {
+      return {
+        items: {
+          type: Array,
+          observer: '_resetCursorStops',
+        },
+        downArrow: Boolean,
+        topContent: Object,
+        horizontalAlign: {
+          type: String,
+          value: 'left',
+        },
 
-      /**
-       * Style the dropdown trigger as a link (rather than a button).
-       */
-      link: {
-        type: Boolean,
-        value: false,
-      },
+        /**
+         * Style the dropdown trigger as a link (rather than a button).
+         */
+        link: {
+          type: Boolean,
+          value: false,
+        },
 
-      verticalOffset: {
-        type: Number,
-        value: 40,
-      },
+        verticalOffset: {
+          type: Number,
+          value: 40,
+        },
 
-      /**
-       * List the IDs of dropdown buttons to be disabled. (Note this only
-       * diisables bittons and not link entries.)
-       */
-      disabledIds: {
-        type: Array,
-        value() { return []; },
-      },
+        /**
+         * List the IDs of dropdown buttons to be disabled. (Note this only
+         * diisables bittons and not link entries.)
+         */
+        disabledIds: {
+          type: Array,
+          value() { return []; },
+        },
 
-      /**
-       * The elements of the list.
-       */
-      _listElements: {
-        type: Array,
-        value() { return []; },
-      },
-    },
+        /**
+         * The elements of the list.
+         */
+        _listElements: {
+          type: Array,
+          value() { return []; },
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
-    keyBindings: {
-      'down': '_handleDown',
-      'enter space': '_handleEnter',
-      'tab': '_handleTab',
-      'up': '_handleUp',
-    },
+    get keyBindings() {
+      return {
+        'down': '_handleDown',
+        'enter space': '_handleEnter',
+        'tab': '_handleTab',
+        'up': '_handleUp',
+      };
+    }
 
     /**
      * Handle the up key.
@@ -103,7 +111,7 @@
       } else {
         this._open();
       }
-    },
+    }
 
     /**
      * Handle the down key.
@@ -118,7 +126,7 @@
       } else {
         this._open();
       }
-    },
+    }
 
     /**
      * Handle the tab key.
@@ -131,7 +139,7 @@
         e.preventDefault();
         e.stopPropagation();
       }
-    },
+    }
 
     /**
      * Handle the enter key.
@@ -150,7 +158,7 @@
       } else {
         this._open();
       }
-    },
+    }
 
     /**
      * Handle a click on the iron-dropdown element.
@@ -159,7 +167,7 @@
      */
     _handleDropdownClick(e) {
       this._close();
-    },
+    }
 
     /**
      * Hanlde a click on the button to open the dropdown.
@@ -174,7 +182,7 @@
       } else {
         this._open();
       }
-    },
+    }
 
     /**
      * Open the dropdown and initialize the cursor.
@@ -184,7 +192,7 @@
       this._resetCursorStops();
       this.$.cursor.setCursorAtIndex(0);
       this.$.cursor.target.focus();
-    },
+    }
 
     _close() {
       // async is needed so that that the click event is fired before the
@@ -192,7 +200,7 @@
       this.async(() => {
         this.$.dropdown.close();
       }, 1);
-    },
+    }
 
     /**
      * Get the class for a top-content item based on the given boolean.
@@ -202,7 +210,7 @@
      */
     _getClassIfBold(bold) {
       return bold ? 'bold-text' : '';
-    },
+    }
 
     /**
      * Build a URL for the given host and path. The base URL will be only added,
@@ -216,7 +224,7 @@
       const base = path.startsWith(this.getBaseUrl()) ?
         '' : this.getBaseUrl();
       return '//' + host + base + path;
-    },
+    }
 
     /**
      * Build a scheme-relative URL for the current host. Will include the base
@@ -229,7 +237,7 @@
     _computeRelativeURL(path) {
       const host = window.location.host;
       return this._computeURLHelper(host, path);
-    },
+    }
 
     /**
      * Compute the URL for a link object.
@@ -245,7 +253,7 @@
         return link.url;
       }
       return this._computeRelativeURL(link.url);
-    },
+    }
 
     /**
      * Compute the value for the rel attribute of an anchor for the given link
@@ -260,7 +268,7 @@
       if (link.target) { return REL_NOOPENER; }
       if (link.external) { return REL_EXTERNAL; }
       return null;
-    },
+    }
 
     /**
      * Handle a click on an item of the dropdown.
@@ -276,7 +284,7 @@
         }
         this.dispatchEvent(new CustomEvent('tap-item-' + id));
       }
-    },
+    }
 
     /**
      * If a dropdown item is shown as a button, get the class for the button.
@@ -288,7 +296,7 @@
      */
     _computeDisabledClass(id, disabledIdsRecord) {
       return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
-    },
+    }
 
     /**
      * Recompute the stops for the dropdown item cursor.
@@ -299,14 +307,16 @@
         this._listElements = Array.from(
             Polymer.dom(this.root).querySelectorAll('li'));
       }
-    },
+    }
 
     _computeHasTooltip(tooltip) {
       return !!tooltip;
-    },
+    }
 
     _computeIsDownload(link) {
       return !!link.download;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrDropdown.is, GrDropdown);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 295f746..4b5ccb4 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-dropdown</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index 45ddfc8..627f948 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -33,17 +33,24 @@
       }
       .viewer {
         background-color: var(--view-background-color);
-        border: 1px solid var(--border-color);
+        border: 1px solid var(--view-background-color);
         border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-1);
         padding: var(--spacing-m);
       }
+      :host([collapsed]) .viewer {
+        max-height: 36em;
+        overflow: hidden;
+      }
       .editor iron-autogrow-textarea {
         background-color: var(--view-background-color);
         width: 100%;
 
+        /* You have to also repeat everything from shared-styles here, because
+           you can only *replace* --iron-autogrow-textarea vars as a whole. */
         --iron-autogrow-textarea: {
-          padding: var(--spacing-m);
           box-sizing: border-box;
+          padding: var(--spacing-m);
           overflow-y: hidden;
           white-space: pre;
         };
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 75c0201..7d3308e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -20,9 +20,16 @@
   const RESTORED_MESSAGE = 'Content restored from a previous edit.';
   const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-  Polymer({
-    is: 'gr-editable-content',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrEditableContent extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-editable-content'; }
     /**
      * Fired when the save button is pressed.
      *
@@ -41,42 +48,40 @@
      * @event show-alert
      */
 
-    properties: {
-      content: {
-        notify: true,
-        type: String,
-      },
-      disabled: {
-        reflectToAttribute: true,
-        type: Boolean,
-        value: false,
-      },
-      editing: {
-        observer: '_editingChanged',
-        type: Boolean,
-        value: false,
-      },
-      removeZeroWidthSpace: Boolean,
-      // If no storage key is provided, content is not stored.
-      storageKey: String,
-      _saveDisabled: {
-        computed: '_computeSaveDisabled(disabled, content, _newContent)',
-        type: Boolean,
-        value: true,
-      },
-      _newContent: {
-        type: String,
-        observer: '_newContentChanged',
-      },
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+    static get properties() {
+      return {
+        content: {
+          notify: true,
+          type: String,
+        },
+        disabled: {
+          reflectToAttribute: true,
+          type: Boolean,
+          value: false,
+        },
+        editing: {
+          observer: '_editingChanged',
+          type: Boolean,
+          value: false,
+        },
+        removeZeroWidthSpace: Boolean,
+        // If no storage key is provided, content is not stored.
+        storageKey: String,
+        _saveDisabled: {
+          computed: '_computeSaveDisabled(disabled, content, _newContent)',
+          type: Boolean,
+          value: true,
+        },
+        _newContent: {
+          type: String,
+          observer: '_newContentChanged',
+        },
+      };
+    }
 
     focusTextarea() {
       this.$$('iron-autogrow-textarea').textarea.focus();
-    },
+    }
 
     _newContentChanged(newContent, oldContent) {
       if (!this.storageKey) { return; }
@@ -88,7 +93,7 @@
           this.$.storage.eraseEditableContentItem(this.storageKey);
         }
       }, STORAGE_DEBOUNCE_INTERVAL_MS);
-    },
+    }
 
     _editingChanged(editing) {
       if (!editing) { return; }
@@ -114,7 +119,7 @@
       this._newContent = this.removeZeroWidthSpace ?
         content.replace(/^R=\u200B/gm, 'R=') :
         content;
-    },
+    }
 
     _computeSaveDisabled(disabled, content, newContent) {
       // Polymer 2: check for undefined
@@ -127,17 +132,19 @@
       }
 
       return disabled || (content === newContent);
-    },
+    }
 
     _handleSave(e) {
       e.preventDefault();
       this.fire('editable-content-save', {content: this._newContent});
-    },
+    }
 
     _handleCancel(e) {
       e.preventDefault();
       this.editing = false;
       this.fire('editable-content-cancel');
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEditableContent.is, GrEditableContent);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index 24db69f..69c9a00 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-content</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 78465e1..917e4b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -50,7 +50,7 @@
         cursor: pointer;
       }
       #dropdown {
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        box-shadow: var(--elevation-level-2);
       }
       .inputContainer {
         background-color: var(--dialog-background-color);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index 4485551..ef5bb8c 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -20,76 +20,86 @@
   const AWAIT_MAX_ITERS = 10;
   const AWAIT_STEP = 5;
 
-  Polymer({
-    is: 'gr-editable-label',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrEditableLabel extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-editable-label'; }
     /**
      * Fired when the value is changed.
      *
      * @event changed
      */
 
-    properties: {
-      labelText: String,
-      editing: {
-        type: Boolean,
-        value: false,
-      },
-      value: {
-        type: String,
-        notify: true,
-        value: '',
-        observer: '_updateTitle',
-      },
-      placeholder: {
-        type: String,
-        value: '',
-      },
-      readOnly: {
-        type: Boolean,
-        value: false,
-      },
-      uppercase: {
-        type: Boolean,
-        reflectToAttribute: true,
-        value: false,
-      },
-      maxLength: Number,
-      _inputText: String,
-      // This is used to push the iron-input element up on the page, so
-      // the input is placed in approximately the same position as the
-      // trigger.
-      _verticalOffset: {
-        type: Number,
-        readOnly: true,
-        value: -30,
-      },
-    },
+    static get properties() {
+      return {
+        labelText: String,
+        editing: {
+          type: Boolean,
+          value: false,
+        },
+        value: {
+          type: String,
+          notify: true,
+          value: '',
+          observer: '_updateTitle',
+        },
+        placeholder: {
+          type: String,
+          value: '',
+        },
+        readOnly: {
+          type: Boolean,
+          value: false,
+        },
+        uppercase: {
+          type: Boolean,
+          reflectToAttribute: true,
+          value: false,
+        },
+        maxLength: Number,
+        _inputText: String,
+        // This is used to push the iron-input element up on the page, so
+        // the input is placed in approximately the same position as the
+        // trigger.
+        _verticalOffset: {
+          type: Number,
+          readOnly: true,
+          value: -30,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+    /** @override */
+    ready() {
+      super.ready();
+      this._ensureAttribute('tabindex', '0');
+    }
 
-    keyBindings: {
-      enter: '_handleEnter',
-      esc: '_handleEsc',
-    },
-
-    hostAttributes: {
-      tabindex: '0',
-    },
+    get keyBindings() {
+      return {
+        enter: '_handleEnter',
+        esc: '_handleEsc',
+      };
+    }
 
     _usePlaceholder(value, placeholder) {
       return (!value || !value.length) && placeholder;
-    },
+    }
 
     _computeLabel(value, placeholder) {
       if (this._usePlaceholder(value, placeholder)) {
         return placeholder;
       }
       return value;
-    },
+    }
 
     _showDropdown() {
       if (this.readOnly || this.editing) { return; }
@@ -98,13 +108,13 @@
         if (!this.$.input.value) { return; }
         this._nativeInput.setSelectionRange(0, this.$.input.value.length);
       });
-    },
+    }
 
     open() {
       return this._open().then(() => {
         this._nativeInput.focus();
       });
-    },
+    }
 
     _open(...args) {
       this.$.dropdown.open();
@@ -115,7 +125,7 @@
         Polymer.IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
         this._awaitOpen(resolve);
       });
-    },
+    }
 
     /**
      * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
@@ -133,11 +143,11 @@
         }, AWAIT_STEP);
       };
       step.call(this);
-    },
+    }
 
     _id() {
       return this.getAttribute('id') || 'global';
-    },
+    }
 
     _save() {
       if (!this.editing) { return; }
@@ -145,20 +155,20 @@
       this.value = this._inputText;
       this.editing = false;
       this.fire('changed', this.value);
-    },
+    }
 
     _cancel() {
       if (!this.editing) { return; }
       this.$.dropdown.close();
       this.editing = false;
       this._inputText = this.value;
-    },
+    }
 
     get _nativeInput() {
       // In Polymer 2, the namespace of nativeInput
       // changed from input to nativeInput
       return this.$.input.$.nativeInput || this.$.input.$.input;
-    },
+    }
 
     _handleEnter(e) {
       e = this.getKeyboardEvent(e);
@@ -167,7 +177,7 @@
         e.preventDefault();
         this._save();
       }
-    },
+    }
 
     _handleEsc(e) {
       e = this.getKeyboardEvent(e);
@@ -176,7 +186,7 @@
         e.preventDefault();
         this._cancel();
       }
-    },
+    }
 
     _computeLabelClass(readOnly, value, placeholder) {
       const classes = [];
@@ -185,10 +195,12 @@
         classes.push('placeholder');
       }
       return classes.join(' ');
-    },
+    }
 
     _updateTitle(value) {
       this.setAttribute('title', this._computeLabel(value, this.placeholder));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrEditableLabel.is, GrEditableLabel);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index 7ff0a14..1690c3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -166,7 +166,6 @@
       MockInteractions.tap(element.$.saveBtn, 13);
     });
 
-
     test('edit and then escape key', done => {
       const editedStub = sandbox.stub();
       element.addEventListener('changed', editedStub);
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
index 643e918..4509dfe 100644
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
@@ -145,4 +145,4 @@
       });
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
index 226092f..14285b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
@@ -41,7 +41,7 @@
       }
       .fixedAtTop {
         border-bottom: 1px solid #a4a4a4;
-        box-shadow: 0 4px 4px rgba(0,0,0,0.1);
+        box-shadow: var(--elevation-level-2);
       }
     </style>
     <header id="header" class$="[[_computeHeaderClass(_headerFloating, _topLast)]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
index 2c32709..02e23e8 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -17,47 +17,54 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-fixed-panel',
+  /** @extends Polymer.Element */
+  class GrFixedPanel extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-fixed-panel'; }
 
-    properties: {
-      floatingDisabled: Boolean,
-      readyForMeasure: {
-        type: Boolean,
-        observer: '_readyForMeasureObserver',
-      },
-      keepOnScroll: {
-        type: Boolean,
-        value: false,
-      },
-      _isMeasured: {
-        type: Boolean,
-        value: false,
-      },
+    static get properties() {
+      return {
+        floatingDisabled: Boolean,
+        readyForMeasure: {
+          type: Boolean,
+          observer: '_readyForMeasureObserver',
+        },
+        keepOnScroll: {
+          type: Boolean,
+          value: false,
+        },
+        _isMeasured: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * Initial offset from the top of the document, in pixels.
-       */
-      _topInitial: Number,
+        /**
+         * Initial offset from the top of the document, in pixels.
+         */
+        _topInitial: Number,
 
-      /**
-       * Current offset from the top of the window, in pixels.
-       */
-      _topLast: Number,
+        /**
+         * Current offset from the top of the window, in pixels.
+         */
+        _topLast: Number,
 
-      _headerHeight: Number,
-      _headerFloating: {
-        type: Boolean,
-        value: false,
-      },
-      _observer: {
-        type: Object,
-        value: null,
-      },
-      _webComponentsReady: Boolean,
-    },
+        _headerHeight: Number,
+        _headerFloating: {
+          type: Boolean,
+          value: false,
+        },
+        _observer: {
+          type: Object,
+          value: null,
+        },
+        _webComponentsReady: Boolean,
+      };
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       if (this.floatingDisabled) {
         return;
       }
@@ -69,21 +76,23 @@
       this.listen(window, 'scroll', '_updateOnScroll');
       this._observer = new MutationObserver(this.update.bind(this));
       this._observer.observe(this.$.header, {childList: true, subtree: true});
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'scroll', '_updateOnScroll');
       this.unlisten(window, 'resize', 'update');
       if (this._observer) {
         this._observer.disconnect();
       }
-    },
+    }
 
     _readyForMeasureObserver(readyForMeasure) {
       if (readyForMeasure) {
         this.update();
       }
-    },
+    }
 
     _computeHeaderClass(headerFloating, topLast) {
       const fixedAtTop = this.keepOnScroll && topLast === 0;
@@ -91,7 +100,7 @@
         headerFloating ? 'floating' : '',
         fixedAtTop ? 'fixedAtTop' : '',
       ].join(' ');
-    },
+    }
 
     unfloat() {
       if (this.floatingDisabled) {
@@ -100,19 +109,19 @@
       this.$.header.style.top = '';
       this._headerFloating = false;
       this.updateStyles({'--header-height': ''});
-    },
+    }
 
     update() {
       this.debounce('update', () => {
         this._updateDebounced();
       }, 100);
-    },
+    }
 
     _updateOnScroll() {
       this.debounce('update', () => {
         this._updateDebounced();
       });
-    },
+    }
 
     _updateDebounced() {
       if (this.floatingDisabled) {
@@ -121,11 +130,11 @@
       this._isMeasured = false;
       this._maybeFloatHeader();
       this._reposition();
-    },
+    }
 
     _getElementTop() {
       return this.getBoundingClientRect().top;
-    },
+    }
 
     _reposition() {
       if (!this._headerFloating) {
@@ -155,7 +164,7 @@
         }
         this._topLast = newTop;
       }
-    },
+    }
 
     _measure() {
       if (this._isMeasured) {
@@ -171,12 +180,12 @@
       this._topInitial =
         this.getBoundingClientRect().top + document.body.scrollTop;
       this._isMeasured = true;
-    },
+    }
 
     _isFloatingNeeded() {
       return this.keepOnScroll ||
         document.body.scrollWidth > document.body.clientWidth;
-    },
+    }
 
     _maybeFloatHeader() {
       if (!this._isFloatingNeeded()) {
@@ -186,11 +195,13 @@
       if (this._isMeasured) {
         this._floatHeader();
       }
-    },
+    }
 
     _floatHeader() {
       this.updateStyles({'--header-height': this._headerHeight + 'px'});
       this._headerFloating = true;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrFixedPanel.is, GrFixedPanel);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
index 75e9901..419b3e7 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-fixed-panel</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index feae173..7483590 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -20,30 +20,39 @@
   // eslint-disable-next-line no-unused-vars
   const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
 
-  Polymer({
-    is: 'gr-formatted-text',
+  /** @extends Polymer.Element */
+  class GrFormattedText extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-formatted-text'; }
 
-    properties: {
-      content: {
-        type: String,
-        observer: '_contentChanged',
-      },
-      config: Object,
-      noTrailingMargin: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        content: {
+          type: String,
+          observer: '_contentChanged',
+        },
+        config: Object,
+        noTrailingMargin: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    observers: [
-      '_contentOrConfigChanged(content, config)',
-    ],
+    static get observers() {
+      return [
+        '_contentOrConfigChanged(content, config)',
+      ];
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       if (this.noTrailingMargin) {
         this.classList.add('noTrailingMargin');
       }
-    },
+    }
 
     /**
      * Get the plain text as it appears in the generated DOM.
@@ -56,7 +65,7 @@
      */
     getTextContent() {
       return this._blocksToText(this._computeBlocks(this.content));
-    },
+    }
 
     _contentChanged(content) {
       // In the case where the config may not be set (perhaps due to the
@@ -64,7 +73,7 @@
       // prevent waiting on the config to display the text.
       if (this.config) { return; }
       this._contentOrConfigChanged(content);
-    },
+    }
 
     /**
      * Given a source string, update the DOM inside #container.
@@ -81,7 +90,7 @@
       for (const node of this._computeNodes(this._computeBlocks(content))) {
         container.appendChild(node);
       }
-    },
+    }
 
     /**
      * Given a source string, parse into an array of block objects. Each block
@@ -127,7 +136,7 @@
         }
       }
       return result;
-    },
+    }
 
     /**
      * Take a block of comment text that contains a list and potentially
@@ -201,7 +210,7 @@
       if (block !== null) {
         out.push(block);
       }
-    },
+    }
 
     _makeQuote(p) {
       const quotedLines = p
@@ -212,21 +221,21 @@
         type: 'quote',
         blocks: this._computeBlocks(quotedLines),
       };
-    },
+    }
 
     _isQuote(p) {
       return p.startsWith('> ') || p.startsWith(' > ');
-    },
+    }
 
     _isPreFormat(p) {
       return p.includes('\n ') || p.includes('\n\t') ||
           p.startsWith(' ') || p.startsWith('\t');
-    },
+    }
 
     _isList(p) {
       return p.includes('\n- ') || p.includes('\n* ') ||
           p.startsWith('- ') || p.startsWith('* ');
-    },
+    }
 
     /**
      * @param {string} content
@@ -241,7 +250,7 @@
         text.classList.add('pre');
       }
       return text;
-    },
+    }
 
     /**
      * Map an array of block objects to an array of DOM nodes.
@@ -279,7 +288,7 @@
           return ul;
         }
       });
-    },
+    }
 
     _blocksToText(blocks) {
       return blocks.map(block => {
@@ -293,6 +302,8 @@
           return block.items.join('\n');
         }
       }).join('\n\n');
-    },
-  });
+    }
+  }
+
+  customElements.define(GrFormattedText.is, GrFormattedText);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
index 801190a..92fbc74 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-editable-label</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
index c77ea81..fcc04ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.html
@@ -36,7 +36,7 @@
       }
       #hovercard {
         background: var(--dialog-background-color);
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        box-shadow: var(--elevation-level-2);
         padding: var(--spacing-l);
       }
     </style>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
index 3a43191..ce34d3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -25,82 +25,94 @@
    */
   const DIAGONAL_OVERFLOW = 15;
 
-  Polymer({
-    is: 'gr-hovercard',
+  /** @extends Polymer.Element */
+  class GrHovercard extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-hovercard'; }
 
-    properties: {
+    static get properties() {
+      return {
       /**
        * @type {?}
        */
-      _target: Object,
+        _target: Object,
 
-      /**
-       * Determines whether or not the hovercard is visible.
-       *
-       * @type {boolean}
-       */
-      _isShowing: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * The `id` of the element that the hovercard is anchored to.
-       *
-       * @type {string}
-       */
-      for: {
-        type: String,
-        observer: '_forChanged',
-      },
+        /**
+         * Determines whether or not the hovercard is visible.
+         *
+         * @type {boolean}
+         */
+        _isShowing: {
+          type: Boolean,
+          value: false,
+        },
+        /**
+         * The `id` of the element that the hovercard is anchored to.
+         *
+         * @type {string}
+         */
+        for: {
+          type: String,
+          observer: '_forChanged',
+        },
 
-      /**
-       * The spacing between the top of the hovercard and the element it is
-       * anchored to.
-       *
-       * @type {number}
-       */
-      offset: {
-        type: Number,
-        value: 14,
-      },
+        /**
+         * The spacing between the top of the hovercard and the element it is
+         * anchored to.
+         *
+         * @type {number}
+         */
+        offset: {
+          type: Number,
+          value: 14,
+        },
 
-      /**
-       * Positions the hovercard to the top, right, bottom, left, bottom-left,
-       * bottom-right, top-left, or top-right of its content.
-       *
-       * @type {string}
-       */
-      position: {
-        type: String,
-        value: 'bottom',
-      },
+        /**
+         * Positions the hovercard to the top, right, bottom, left, bottom-left,
+         * bottom-right, top-left, or top-right of its content.
+         *
+         * @type {string}
+         */
+        position: {
+          type: String,
+          value: 'bottom',
+        },
 
-      container: Object,
-      /**
-       * ID for the container element.
-       *
-       * @type {string}
-       */
-      containerId: {
-        type: String,
-        value: 'gr-hovercard-container',
-      },
-    },
+        container: Object,
+        /**
+         * ID for the container element.
+         *
+         * @type {string}
+         */
+        containerId: {
+          type: String,
+          value: 'gr-hovercard-container',
+        },
+      };
+    }
 
-    listeners: {
-      mouseleave: 'hide',
-    },
-
+    /** @override */
     attached() {
+      super.attached();
       if (!this._target) { this._target = this.target; }
       this.listen(this._target, 'mouseenter', 'show');
       this.listen(this._target, 'focus', 'show');
       this.listen(this._target, 'mouseleave', 'hide');
       this.listen(this._target, 'blur', 'hide');
       this.listen(this._target, 'click', 'hide');
-    },
+    }
 
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('mouseleave',
+          e => this.hide(e));
+    }
+
+    /** @override */
     ready() {
+      super.ready();
       // First, check to see if the container has already been created.
       this.container = Gerrit.getRootElement()
           .querySelector('#' + this.containerId);
@@ -111,7 +123,7 @@
       this.container = document.createElement('div');
       this.container.setAttribute('id', this.containerId);
       Gerrit.getRootElement().appendChild(this.container);
-    },
+    }
 
     removeListeners() {
       this.unlisten(this._target, 'mouseenter', 'show');
@@ -119,7 +131,7 @@
       this.unlisten(this._target, 'mouseleave', 'hide');
       this.unlisten(this._target, 'blur', 'hide');
       this.unlisten(this._target, 'click', 'hide');
-    },
+    }
 
     /**
      * Returns the target element that the hovercard is anchored to (the `id` of
@@ -140,7 +152,7 @@
           parentNode;
       }
       return target;
-    },
+    }
 
     /**
      * Hides/closes the hovercard. This occurs when the user triggers the
@@ -184,7 +196,7 @@
       if (this.container.contains(this)) {
         this.container.removeChild(this);
       }
-    },
+    }
 
     /**
      * Shows/opens the hovercard. This occurs when the user triggers the
@@ -207,7 +219,7 @@
 
       // Trigger the transition
       this.classList.add(HOVER_CLASS);
-    },
+    }
 
     /**
      * Updates the hovercard's position based on the `position` attribute
@@ -306,7 +318,7 @@
       // Set the hovercard's position
       cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
       this.style.cssText = cssText;
-    },
+    }
 
     /**
      * Responds to a change in the `for` value and gets the updated `target`
@@ -316,6 +328,8 @@
      */
     _forChanged() {
       this._target = this.target;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrHovercard.is, GrHovercard);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
index 8e79f65..35d736c 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-hovercard</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 7bd6f48..5ecae00 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -54,6 +54,8 @@
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
       <!-- This is a custom PolyGerrit SVG -->
@@ -76,6 +78,7 @@
       <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
+      <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
index 3223636..66e7a74 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-context</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
index 4ba9820..4025cdc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -17,6 +17,7 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrAnnotationActionsInterface(plugin) {
     this.plugin = plugin;
     // Return this instance when there is an annotatediff event.
@@ -100,7 +101,7 @@
    *        https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
    *        implemented.
    *
-   * @param {String} checkboxLabel Will be used as the label for the checkbox.
+   * @param {string} checkboxLabel Will be used as the label for the checkbox.
    *     Optional. "Enable" is used if this is not specified.
    * @param {function(HTMLElement)} onAttached The function that will be called
    *     when the checkbox is attached to the page.
@@ -132,10 +133,10 @@
    * layers. Intended to be called by the plugin when all required data for
    * annotation is available.
    *
-   * @param {String} path The file path whose listeners should be notified.
-   * @param {Number} start The line where the update starts.
-   * @param {Number} end The line where the update ends.
-   * @param {String} side The side of the update ('left' or 'right').
+   * @param {string} path The file path whose listeners should be notified.
+   * @param {number} start The line where the update starts.
+   * @param {number} end The line where the update ends.
+   * @param {string} side The side of the update ('left' or 'right').
    */
   GrAnnotationActionsInterface.prototype.notify = function(
       path, startRange, endRange, side) {
@@ -153,9 +154,9 @@
    * Should be called to register annotation layers by the framework. Not
    * intended to be called by plugins.
    *
-   * @param {String} path The file path (eg: /COMMIT_MSG').
-   * @param {String} changeNum The Gerrit change number.
-   * @param {String} patchNum The Gerrit patch number.
+   * @param {string} path The file path (eg: /COMMIT_MSG').
+   * @param {string} changeNum The Gerrit change number.
+   * @param {string} patchNum The Gerrit patch number.
    */
   GrAnnotationActionsInterface.prototype.getLayer = function(
       path, changeNum, patchNum) {
@@ -168,9 +169,10 @@
   /**
    * Used to create an instance of the Annotation Layer interface.
    *
-   * @param {String} path The file path (eg: /COMMIT_MSG').
-   * @param {String} changeNum The Gerrit change number.
-   * @param {String} patchNum The Gerrit patch number.
+   * @constructor
+   * @param {string} path The file path (eg: /COMMIT_MSG').
+   * @param {string} changeNum The Gerrit change number.
+   * @param {string} patchNum The Gerrit patch number.
    * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
    *     that will be called when the AnnotationLayer is ready to annotate.
    */
@@ -186,7 +188,7 @@
   /**
    * Register a listener for layer updates.
    *
-   * @param {function(Number, Number, String)} fn The update handler function.
+   * @param {Function} fn The update handler function.
    *     Should accept as arguments the line numbers for the start and end of
    *     the update and the side as a string.
    */
@@ -213,9 +215,9 @@
   /**
    * Notify Layer listeners of changes to annotations.
    *
-   * @param {Number} start The line where the update starts.
-   * @param {Number} end The line where the update ends.
-   * @param {String} side The side of the update. ('left' or 'right')
+   * @param {number} start The line where the update starts.
+   * @param {number} end The line where the update ends.
+   * @param {string} side The side of the update. ('left' or 'right')
    */
   AnnotationLayer.prototype.notifyListeners = function(
       startRange, endRange, side) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
index 987b551..98ff954 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-annotation-actions-js-api-js-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
index 2925736..d5e65da 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
@@ -51,7 +51,11 @@
       return url.pathname;
     }
     const base = Gerrit.BaseUrlBehavior.getBaseUrl();
-    const pathname = url.pathname.replace(base, '');
+    let pathname = url.pathname.replace(base, '');
+    // Load from ASSETS_PATH
+    if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
+      pathname = url.href.replace(window.ASSETS_PATH, '');
+    }
     // Site theme is server from predefined path.
     if (pathname === '/static/gerrit-theme.html') {
       return 'gerrit-theme';
@@ -71,27 +75,28 @@
 
   // TODO(taoalpha): to be deprecated.
   function send(method, url, opt_callback, opt_payload) {
-    return getRestAPI().send(method, url, opt_payload).then(response => {
-      if (response.status < 200 || response.status >= 300) {
-        return response.text().then(text => {
-          if (text) {
-            return Promise.reject(text);
+    return getRestAPI().send(method, url, opt_payload)
+        .then(response => {
+          if (response.status < 200 || response.status >= 300) {
+            return response.text().then(text => {
+              if (text) {
+                return Promise.reject(new Error(text));
+              } else {
+                return Promise.reject(new Error(response.status));
+              }
+            });
           } else {
-            return Promise.reject(response.status);
+            return getRestAPI().getResponseObject(response);
           }
+        })
+        .then(response => {
+          if (opt_callback) {
+            opt_callback(response);
+          }
+          return response;
         });
-      } else {
-        return getRestAPI().getResponseObject(response);
-      }
-    }).then(response => {
-      if (opt_callback) {
-        opt_callback(response);
-      }
-      return response;
-    });
   }
 
-
   // TEST only methods / properties
 
   function testOnly_resetInternalState() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
index c407aa8..b43796f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -29,7 +29,6 @@
 <script>void(0);</script>
 
 <script>
-
   const PRELOADED_PROTOCOL = 'preloaded:';
 
   suite('gr-api-utils tests', () => {
@@ -73,6 +72,15 @@
             'gerrit-theme'
         );
       });
+
+      test('with ASSETS_PATH', () => {
+        window.ASSETS_PATH = 'http://cdn.com/2';
+        assert.equal(
+            getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
+            'a'
+        );
+        window.ASSETS_PATH = undefined;
+      });
     });
   });
 </script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index 5658245..7c4c817 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -62,9 +62,8 @@
 
   GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
     ensureEl(this);
-    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(k => {
-      return k !== key;
-    });
+    this._el.primaryActionKeys = this._el.primaryActionKeys
+        .filter(k => k !== key);
   };
 
   GrChangeActionsInterface.prototype.hideQuickApproveAction = function() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 7332877..cbe2d37 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-actions-js-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index bef7094..66f48f3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -33,6 +33,7 @@
 
   /**
    * @deprecated
+   * @constructor
    */
   function GrChangeReplyInterfaceOld(el) {
     this._el = el;
@@ -56,8 +57,8 @@
   function GrChangeReplyInterface(plugin, el) {
     GrChangeReplyInterfaceOld.call(this, el);
     this.plugin = plugin;
-    this._hookName = (plugin.getPluginName() || 'test') + '-autogenerated-'
-      + String(Math.random()).split('.')[1];
+    this._hookName = (plugin.getPluginName() || 'test') + '-autogenerated-' +
+      String(Math.random()).split('.')[1];
   }
   GrChangeReplyInterface.prototype._hookName = '';
   GrChangeReplyInterface.prototype._hookClass = null;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 842a2fe..7955aa4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-change-reply-js-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
index 73a2480..b0fe09e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
@@ -109,21 +109,22 @@
 
   Gerrit.delete = function(url, opt_callback) {
     console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-    return getRestAPI().send('DELETE', url).then(response => {
-      if (response.status !== 204) {
-        return response.text().then(text => {
-          if (text) {
-            return Promise.reject(text);
-          } else {
-            return Promise.reject(response.status);
+    return getRestAPI().send('DELETE', url)
+        .then(response => {
+          if (response.status !== 204) {
+            return response.text().then(text => {
+              if (text) {
+                return Promise.reject(new Error(text));
+              } else {
+                return Promise.reject(new Error(response.status));
+              }
+            });
           }
+          if (opt_callback) {
+            opt_callback(response);
+          }
+          return response;
         });
-      }
-      if (opt_callback) {
-        opt_callback(response);
-      }
-      return response;
-    });
   };
 
   Gerrit.awaitPluginsLoaded = function() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
index e81b8aa..7b03308 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 9eca3a9..6f9268c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -25,6 +25,7 @@
     COMMIT_MSG_EDIT: 'commitmsgedit',
     COMMENT: 'comment',
     REVERT: 'revert',
+    REVERT_SUBMISSION: 'revert_submission',
     POST_REVERT: 'postrevert',
     ANNOTATE_DIFF: 'annotatediff',
     ADMIN_MENU_LINKS: 'admin-menu-links',
@@ -36,24 +37,35 @@
     REPLY_DIALOG: 'replydialog',
   };
 
-  Polymer({
-    is: 'gr-js-api-interface',
+  /**
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @extends Polymer.Element
+   */
+  class GrJsApiInterface extends Polymer.mixinBehaviors( [
+    Gerrit.PatchSetBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-js-api-interface'; }
 
-    properties: {
-      _elements: {
-        type: Object,
-        value: {}, // Shared across all instances.
-      },
-      _eventCallbacks: {
-        type: Object,
-        value: {}, // Shared across all instances.
-      },
-    },
+    constructor() {
+      super();
+      this.Element = Element;
+      this.EventType = EventType;
+    }
 
-    behaviors: [Gerrit.PatchSetBehavior],
-
-    Element,
-    EventType,
+    static get properties() {
+      return {
+        _elements: {
+          type: Object,
+          value: {}, // Shared across all instances.
+        },
+        _eventCallbacks: {
+          type: Object,
+          value: {}, // Shared across all instances.
+        },
+      };
+    }
 
     handleEvent(type, detail) {
       Gerrit.awaitPluginsLoaded().then(() => {
@@ -79,22 +91,22 @@
             break;
         }
       });
-    },
+    }
 
     addElement(key, el) {
       this._elements[key] = el;
-    },
+    }
 
     getElement(key) {
       return this._elements[key];
-    },
+    }
 
     addEventCallback(eventName, callback) {
       if (!this._eventCallbacks[eventName]) {
         this._eventCallbacks[eventName] = [];
       }
       this._eventCallbacks[eventName].push(callback);
-    },
+    }
 
     canSubmitChange(change, revision) {
       const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
@@ -108,14 +120,14 @@
       });
 
       return !cancelSubmit;
-    },
+    }
 
     _removeEventCallbacks() {
       for (const k in EventType) {
         if (!EventType.hasOwnProperty(k)) { continue; }
         this._eventCallbacks[EventType[k]] = [];
       }
-    },
+    }
 
     _handleHistory(detail) {
       for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
@@ -125,7 +137,7 @@
           console.error(err);
         }
       }
-    },
+    }
 
     _handleShowChange(detail) {
       // Note (issue 8221) Shallow clone the change object and add a mergeable
@@ -160,7 +172,7 @@
           console.error(err);
         }
       }
-    },
+    }
 
     handleCommitMessage(change, msg) {
       for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
@@ -170,7 +182,7 @@
           console.error(err);
         }
       }
-    },
+    }
 
     _handleComment(detail) {
       for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
@@ -180,7 +192,7 @@
           console.error(err);
         }
       }
-    },
+    }
 
     _handleLabelChange(detail) {
       for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
@@ -190,7 +202,7 @@
           console.error(err);
         }
       }
-    },
+    }
 
     _handleHighlightjsLoaded(detail) {
       for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
@@ -200,7 +212,7 @@
           console.error(err);
         }
       }
-    },
+    }
 
     modifyRevertMsg(change, revertMsg, origMsg) {
       for (const cb of this._getEventCallbacks(EventType.REVERT)) {
@@ -211,7 +223,18 @@
         }
       }
       return revertMsg;
-    },
+    }
+
+    modifyRevertSubmissionMsg(change, revertSubmissionMsg, origMsg) {
+      for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
+        try {
+          revertSubmissionMsg = cb(change, revertSubmissionMsg, origMsg);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+      return revertSubmissionMsg;
+    }
 
     getDiffLayers(path, changeNum, patchNum) {
       const layers = [];
@@ -225,35 +248,22 @@
         }
       }
       return layers;
-    },
+    }
 
     /**
      * Retrieves coverage data possibly provided by a plugin.
      *
      * Will wait for plugins to be loaded. If multiple plugins offer a coverage
-     * provider, the first one is used. If no plugin offers a coverage provider,
-     * will resolve to [].
+     * provider, the first one is returned. If no plugin offers a coverage provider,
+     * will resolve to null.
      *
-     * @param {string|number} changeNum
-     * @param {string} path
-     * @param {string|number} basePatchNum
-     * @param {string|number} patchNum
-     * @return {!Promise<!Array<!Gerrit.CoverageRange>>}
+     * @return {!Promise<?GrAnnotationActionsInterface>}
      */
-    getCoverageRanges(changeNum, path, basePatchNum, patchNum) {
-      return Gerrit.awaitPluginsLoaded().then(() => {
-        for (const annotationApi of
-          this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-          const provider = annotationApi.getCoverageProvider();
-          // Only one coverage provider makes sense. If there are more, then we
-          // simply ignore them.
-          if (provider) {
-            return provider(changeNum, path, basePatchNum, patchNum);
-          }
-        }
-        return [];
-      });
-    },
+    getCoverageAnnotationApi() {
+      return Gerrit.awaitPluginsLoaded()
+          .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
+              .find(api => api.getCoverageProvider()));
+    }
 
     getAdminMenuLinks() {
       const links = [];
@@ -262,7 +272,7 @@
         links.push(...adminApi.getMenuLinks());
       }
       return links;
-    },
+    }
 
     getLabelValuesPostRevert(change) {
       let labels = {};
@@ -274,10 +284,12 @@
         }
       }
       return labels;
-    },
+    }
 
     _getEventCallbacks(type) {
       return this._eventCallbacks[type] || [];
-    },
-  });
+    }
+  }
+
+  customElements.define(GrJsApiInterface.is, GrJsApiInterface);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index ae12940..917c0a9 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-api-interface</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -82,11 +82,33 @@
           'http://test.com/plugins/testplugin/static/test.js');
     });
 
+    test('url for preloaded plugin without ASSETS_PATH', () => {
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'preloaded:testpluginB');
+      assert.equal(plugin.url(),
+          `${window.location.origin}/plugins/testpluginB/`);
+      assert.equal(plugin.url('/static/test.js'),
+          `${window.location.origin}/plugins/testpluginB/static/test.js`);
+    });
+
+    test('url for preloaded plugin without ASSETS_PATH', () => {
+      const oldAssetsPath = window.ASSETS_PATH;
+      window.ASSETS_PATH = 'http://test.com';
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'preloaded:testpluginC');
+      assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
+      assert.equal(plugin.url('/static/test.js'),
+          `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
+      window.ASSETS_PATH = oldAssetsPath;
+    });
+
     test('_send on failure rejects with response text', () => {
       sendStub.returns(Promise.resolve(
           {status: 400, text() { return Promise.resolve('text'); }}));
       return plugin._send().catch(r => {
-        assert.equal(r, 'text');
+        assert.equal(r.message, 'text');
       });
     });
 
@@ -94,7 +116,7 @@
       sendStub.returns(Promise.resolve(
           {status: 400, text() { return Promise.resolve(null); }}));
       return plugin._send().catch(r => {
-        assert.equal(r, '400');
+        assert.equal(r.message, '400');
       });
     });
 
@@ -158,7 +180,7 @@
       }).catch(err => {
         assert.isTrue(sendStub.calledWith(
             'DELETE', 'http://test.com/plugins/testplugin/url'));
-        assert.equal('text', err);
+        assert.equal('text', err.message);
       });
     });
 
@@ -282,11 +304,11 @@
 
     test('submitchange', () => {
       plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return true; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
       assert.isTrue(element.canSubmitChange());
       assert.isTrue(errorStub.calledOnce);
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return false; });
-      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return true; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
       assert.isFalse(element.canSubmitChange());
       assert.isTrue(errorStub.calledTwice);
     });
@@ -302,11 +324,14 @@
       element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
     });
 
-    test('getAccount', done => {
-      plugin.restApi().getLoggedIn().then(loggedIn => {
-        assert.isTrue(loggedIn);
-        done();
-      });
+    test('getLoggedIn', done => {
+      // fake fetch for authCheck
+      sandbox.stub(window, 'fetch', () => Promise.resolve({status: 204}));
+      plugin.restApi().getLoggedIn()
+          .then(loggedIn => {
+            assert.isTrue(loggedIn);
+            done();
+          });
     });
 
     test('attributeHelper', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
index 6da117f..ca43fc0 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-action-context</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -88,13 +88,17 @@
       assert.equal(div.textContent, 'foobar');
     });
 
-    test('button', () => {
+    test('button', done => {
       const clickStub = sandbox.stub();
       const button = instance.button('foo', {onclick: clickStub});
+      // If you don't attach a Polymer element to the DOM, then the ready()
+      // callback will not be called and then e.g. this.$ is undefined.
+      Polymer.dom(document.body).appendChild(button);
       MockInteractions.tap(button);
       flush(() => {
         assert.isTrue(clickStub.called);
         assert.equal(button.textContent, 'foo');
+        done();
       });
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 5f5a74e..274ac19 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -17,6 +17,7 @@
 (function(window) {
   'use strict';
 
+  /** @constructor */
   function GrPluginEndpoints() {
     this._endpoints = {};
     this._callbacks = {};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index 8ed7f14..b39ed6d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-endpoints</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
index 201b683..1359ce8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -114,7 +114,7 @@
       this._pluginListLoaded = true;
 
       plugins.forEach(path => {
-        const url = this._urlFor(path);
+        const url = this._urlFor(path, window.ASSETS_PATH);
         // Skip if preloaded, for bundling.
         if (this.isPluginPreloaded(url)) return;
 
@@ -129,11 +129,11 @@
         });
 
         if (this._isPathEndsWith(url, '.html')) {
-          this._importHtmlPlugin(url, opts && opts[path]);
+          this._importHtmlPlugin(path, opts && opts[path]);
         } else if (this._isPathEndsWith(url, '.js')) {
-          this._loadJsPlugin(url);
+          this._loadJsPlugin(path);
         } else {
-          this._failToLoad(`Unrecognized plugin url ${url}`, url);
+          this._failToLoad(`Unrecognized plugin path ${path}`, path);
         }
       });
 
@@ -182,14 +182,15 @@
         return;
       }
 
-      const pluginObject = this.getPlugin(src);
+      const url = this._urlFor(src);
+      const pluginObject = this.getPlugin(url);
       let plugin = pluginObject && pluginObject.plugin;
       if (!plugin) {
-        plugin = new Plugin(src);
+        plugin = new Plugin(url);
       }
       try {
         callback(plugin);
-        this._pluginInstalled(src, plugin);
+        this._pluginInstalled(url, plugin);
       } catch (e) {
         this._failToLoad(`${e.name}: ${e.message}`, src);
       }
@@ -317,38 +318,79 @@
     }
 
     _importHtmlPlugin(pluginUrl, opts = {}) {
-      // onload (second param) needs to be a function. When null or undefined
-      // were passed, plugins were not loaded correctly.
+      const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+      const urlWithoutAP = this._urlFor(pluginUrl);
+      let onerror = null;
+      if (urlWithAP !== urlWithoutAP) {
+        onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
+      }
+      this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
+    }
+
+    _loadHtmlPlugin(url, sync, onerror) {
+      if (!onerror) {
+        onerror = () => {
+          this._failToLoad(`${url} import error`, url);
+        };
+      }
+
       (Polymer.importHref || Polymer.Base.importHref)(
-          this._urlFor(pluginUrl), () => {},
-          () => this._failToLoad(`${pluginUrl} import error`, pluginUrl),
-          !opts.sync);
+          url, () => {},
+          onerror,
+          !sync);
     }
 
     _loadJsPlugin(pluginUrl) {
-      this._createScriptTag(this._urlFor(pluginUrl));
+      const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+      const urlWithoutAP = this._urlFor(pluginUrl);
+      let onerror = null;
+      if (urlWithAP !== urlWithoutAP) {
+        onerror = () => this._createScriptTag(urlWithoutAP);
+      }
+
+      this._createScriptTag(urlWithAP, onerror);
     }
 
-    _createScriptTag(url) {
+    _createScriptTag(url, onerror) {
+      if (!onerror) {
+        onerror = () => this._failToLoad(`${url} load error`, url);
+      }
+
       const el = document.createElement('script');
       el.defer = true;
-      el.src = url;
-      el.onerror = () => this._failToLoad(`${url} load error`, url);
+      el.setAttribute('src', url);
+      el.onerror = onerror;
       return document.body.appendChild(el);
     }
 
-    _urlFor(pathOrUrl) {
+    _urlFor(pathOrUrl, assetsPath) {
       if (!pathOrUrl) {
         return pathOrUrl;
       }
+
+      // theme is per host, should always load from assetsPath
+      const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html');
+      const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
       if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
           pathOrUrl.startsWith('http')) {
         // Plugins are loaded from another domain or preloaded.
+        if (pathOrUrl.includes(location.host) &&
+          shouldTryLoadFromAssetsPathFirst) {
+          // if is loading from host server, try replace with cdn when assetsPath provided
+          return pathOrUrl
+              .replace(location.origin, assetsPath);
+        }
         return pathOrUrl;
       }
+
       if (!pathOrUrl.startsWith('/')) {
         pathOrUrl = '/' + pathOrUrl;
       }
+
+      if (shouldTryLoadFromAssetsPathFirst) {
+        return assetsPath + pathOrUrl;
+      }
+
       return window.location.origin + getBaseUrl() + pathOrUrl;
     }
 
@@ -366,7 +408,7 @@
             new Promise(resolve => this._loadingResolver = resolve),
             new Promise((_, reject) => timerId = setTimeout(
                 () => {
-                  reject(this._timeout());
+                  reject(new Error(this._timeout()));
                 }, PLUGIN_LOADING_TIMEOUT_MS)),
           ]).then(() => {
             if (timerId) clearTimeout(timerId);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
index ee54319..7ddb666 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-host</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -325,11 +325,11 @@
       let loadJsPluginStub;
       setup(() => {
         importHtmlPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_importHtmlPlugin', url => {
+        sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
           importHtmlPluginStub(url);
         });
         loadJsPluginStub = sandbox.stub();
-        sandbox.stub(Gerrit._pluginLoader, '_loadJsPlugin', url => {
+        sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
           loadJsPluginStub(url);
         });
       });
@@ -346,8 +346,8 @@
 
         assert.isTrue(failToLoadStub.calledOnce);
         assert.isTrue(failToLoadStub.calledWithExactly(
-            `Unrecognized plugin url ${url}/foo/bar`,
-            `${url}/foo/bar`
+            'Unrecognized plugin path foo/bar',
+            'foo/bar'
         ));
       });
 
@@ -367,12 +367,9 @@
         );
       });
 
-
       test('relative path should honor getBaseUrl', () => {
         const testUrl = '/test';
-        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl', () => {
-          return testUrl;
-        });
+        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl', () => testUrl);
 
         Gerrit._loadPlugins([
           'foo/bar.js',
@@ -408,6 +405,72 @@
       });
     });
 
+    suite('With ASSETS_PATH', () => {
+      let importHtmlPluginStub;
+      let loadJsPluginStub;
+      setup(() => {
+        window.ASSETS_PATH = 'https://cdn.com';
+        importHtmlPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_loadHtmlPlugin', url => {
+          importHtmlPluginStub(url);
+        });
+        loadJsPluginStub = sandbox.stub();
+        sandbox.stub(Gerrit._pluginLoader, '_createScriptTag', url => {
+          loadJsPluginStub(url);
+        });
+      });
+
+      teardown(() => {
+        window.ASSETS_PATH = '';
+      });
+
+      test('Should try load plugins from assets path instead', () => {
+        Gerrit._loadPlugins([
+          'foo/bar.js',
+          'foo/bar.html',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+      });
+
+      test('Should honor original path if exists', () => {
+        Gerrit._loadPlugins([
+          'http://e.com/foo/bar.html',
+          'http://e.com/foo/bar.js',
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
+      });
+
+      test('Should try replace current host with assetsPath', () => {
+        const host = window.location.origin;
+        Gerrit._loadPlugins([
+          `${host}/foo/bar.html`,
+          `${host}/foo/bar.js`,
+        ]);
+
+        assert.isTrue(importHtmlPluginStub.calledOnce);
+        assert.isTrue(
+            importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+        );
+        assert.isTrue(loadJsPluginStub.calledOnce);
+        assert.isTrue(
+            loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+      });
+    });
+
     test('adds js plugins will call the body', () => {
       Gerrit._loadPlugins([
         'http://e.com/foo/bar.js',
@@ -490,12 +553,10 @@
 
       test('installing preloaded plugin', () => {
         let plugin;
-        window.ASSETS_PATH = 'http://blips.com/chitz';
         Gerrit.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
         assert.strictEqual(plugin.getPluginName(), 'foo');
         assert.strictEqual(plugin.url('/some/thing.html'),
-            'http://blips.com/chitz/plugins/foo/some/thing.html');
-        delete window.ASSETS_PATH;
+            `${window.location.origin}/plugins/foo/some/thing.html`);
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
index ecfafb5..7f4537b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
@@ -92,9 +92,9 @@
           if (response.status < 200 || response.status >= 300) {
             return response.text().then(text => {
               if (text) {
-                return Promise.reject(text);
+                return Promise.reject(new Error(text));
               } else {
-                return Promise.reject(response.status);
+                return Promise.reject(new Error(response.status));
               }
             });
           } else {
@@ -138,9 +138,9 @@
       if (response.status !== 204) {
         return response.text().then(text => {
           if (text) {
-            return Promise.reject(text);
+            return Promise.reject(new Error(text));
           } else {
-            return Promise.reject(response.status);
+            return Promise.reject(new Error(response.status));
           }
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
index bcbd961..6fb0b19 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-plugin-rest-api</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -121,10 +121,11 @@
           {status: 400, text() { return Promise.resolve('text'); }}));
       return instance.delete('/url').then(r => {
         throw new Error('Should not resolve');
-      }).catch(err => {
-        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-        assert.equal('text', err);
-      });
+      })
+          .catch(err => {
+            assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+            assert.equal('text', err.message);
+          });
     });
 
     test('getLoggedIn', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index b261a90..a8260d7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -17,8 +17,6 @@
 (function(window) {
   'use strict';
 
-  const PRELOADED_PROTOCOL = 'preloaded:';
-
   const PANEL_ENDPOINTS_MAPPING = {
     CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
     CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
@@ -26,6 +24,7 @@
 
   // Import utils methods
   const {
+    PRELOADED_PROTOCOL,
     getPluginNameFromUrl,
     send,
   } = window._apiUtils;
@@ -46,13 +45,17 @@
     STYLE: 'style',
   };
 
+  /**
+   * @constructor
+   * @param {string=} opt_url
+   */
   function Plugin(opt_url) {
     this._domHooks = new GrDomHooksManager(this);
 
     if (!opt_url) {
       console.warn('Plugin not being loaded from /plugins base path.',
           'Unable to determine name.');
-      return;
+      return this;
     }
     this.deprecated = {
       _loadedGwt: deprecatedAPI._loadedGwt.bind(this),
@@ -66,13 +69,6 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
-    if (this._url.protocol === PRELOADED_PROTOCOL) {
-      // Original plugin URL is used in plugin assets URLs calculation.
-      const assetsBaseUrl = window.ASSETS_PATH ||
-          (window.location.origin + Gerrit.BaseUrlBehavior.getBaseUrl());
-      this._url = new URL(assetsBaseUrl + '/plugins/' + this._name +
-          '/static/' + this._name + '.js');
-    }
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -139,9 +135,15 @@
 
   Plugin.prototype.url = function(opt_path) {
     const relPath = '/plugins/' + this._name + (opt_path || '/');
+    const sameOriginPath = window.location.origin +
+      `${Gerrit.BaseUrlBehavior.getBaseUrl()}${relPath}`;
     if (window.location.origin === this._url.origin) {
       // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
-      return this._url.origin + Gerrit.BaseUrlBehavior.getBaseUrl() + relPath;
+      return sameOriginPath;
+    } else if (this._url.protocol === PRELOADED_PROTOCOL) {
+      // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
+      return window.ASSETS_PATH ? `${window.ASSETS_PATH}${relPath}` :
+        sameOriginPath;
     } else {
       // Plugin loaded from assets bundle, expect assets placed along with it.
       return this._url.href.split('/plugins/' + this._name)[0] + relPath;
@@ -228,7 +230,7 @@
    * @example
    * const pluginRestApi = plugin.restApi(plugin.url());
    *
-   * @param {string} Base url for subsequent .get(), .post() etc requests.
+   * @param {string=} opt_prefix url for subsequent .get(), .post() etc requests.
    */
   Plugin.prototype.restApi = function(opt_prefix) {
     return new GrPluginRestApi(opt_prefix);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
index 63a528e..b17b251 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.html
@@ -28,7 +28,9 @@
 
 <dom-module id="gr-label-info">
   <template strip-whitespace>
-    <style include="gr-voting-styles"></style>
+    <style include="gr-voting-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style include="shared-styles">
       .placeholder {
         color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index 3c27c94..6e99e01 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -17,17 +17,22 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-label-info',
+  /** @extends Polymer.Element */
+  class GrLabelInfo extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-label-info'; }
 
-    properties: {
-      labelInfo: Object,
-      label: String,
-      /** @type {?} */
-      change: Object,
-      account: Object,
-      mutable: Boolean,
-    },
+    static get properties() {
+      return {
+        labelInfo: Object,
+        label: String,
+        /** @type {?} */
+        change: Object,
+        account: Object,
+        mutable: Boolean,
+      };
+    }
 
     /**
      * @param {!Object} labelInfo
@@ -85,7 +90,7 @@
         }
       }
       return result;
-    },
+    }
 
     /**
      * A user is able to delete a vote iff the mutable property is true and the
@@ -94,7 +99,7 @@
      *
      * @param {!Object} reviewer An object describing the reviewer that left the
      *     vote.
-     * @param {Boolean} mutable
+     * @param {boolean} mutable
      * @param {!Object} change
      */
     _computeDeleteClass(reviewer, mutable, change) {
@@ -106,7 +111,7 @@
         return '';
       }
       return 'hidden';
-    },
+    }
 
     /**
      * Closure annotation for Polymer.prototype.splice is off.
@@ -129,18 +134,19 @@
                 target.disabled = false;
                 if (!response.ok) { return; }
                 Gerrit.Nav.navigateToChange(this.change);
-              }).catch(err => {
+              })
+              .catch(err => {
                 target.disabled = false;
                 return;
               });
-    },
+    }
 
     _computeValueTooltip(labelInfo, score) {
       if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
         return '';
       }
       return labelInfo.values[score];
-    },
+    }
 
     /**
      * @param {!Object} labelInfo
@@ -156,6 +162,8 @@
         }
       }
       return '';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLabelInfo.is, GrLabelInfo);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
index 35dc772..adf88d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -17,7 +17,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-label-info</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
index 0de0881..b594757 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -17,11 +17,17 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-label',
+  /**
+   * @appliesMixin Gerrit.TooltipMixin
+   * @extends Polymer.Element
+   */
+  class GrLabel extends Polymer.mixinBehaviors( [
+    Gerrit.TooltipBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-label'; }
+  }
 
-    behaviors: [
-      Gerrit.TooltipBehavior,
-    ],
-  });
+  customElements.define(GrLabel.is, GrLabel);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
index da0b93f..47be6f7 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.html
@@ -38,14 +38,7 @@
       #body {
         display: flex;
       }
-      gr-autocomplete {
-        height: 1.5em;
-        --gr-autocomplete: {
-          border: none;
-        }
-      }
       #trigger {
-        border-left: 1px solid var(--deemphasized-text-color);
         color: var(--deemphasized-text-color);
         cursor: pointer;
         padding-left: var(--spacing-s);
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
index fd0f228..cb5ad7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
@@ -17,60 +17,66 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-labeled-autocomplete',
-
+  /** @extends Polymer.Element */
+  class GrLabeledAutocomplete extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-labeled-autocomplete'; }
     /**
      * Fired when a value is chosen.
      *
      * @event commit
      */
 
-    properties: {
+    static get properties() {
+      return {
 
-      /**
-       * Used just like the query property of gr-autocomplete.
-       *
-       * @type {function(string): Promise<?>}
-       */
-      query: {
-        type: Function,
-        value() {
-          return function() {
-            return Promise.resolve([]);
-          };
+        /**
+         * Used just like the query property of gr-autocomplete.
+         *
+         * @type {function(string): Promise<?>}
+         */
+        query: {
+          type: Function,
+          value() {
+            return function() {
+              return Promise.resolve([]);
+            };
+          },
         },
-      },
 
-      text: {
-        type: String,
-        value: '',
-        notify: true,
-      },
-      label: String,
-      placeholder: String,
-      disabled: Boolean,
+        text: {
+          type: String,
+          value: '',
+          notify: true,
+        },
+        label: String,
+        placeholder: String,
+        disabled: Boolean,
 
-      _autocompleteThreshold: {
-        type: Number,
-        value: 0,
-        readOnly: true,
-      },
-    },
+        _autocompleteThreshold: {
+          type: Number,
+          value: 0,
+          readOnly: true,
+        },
+      };
+    }
 
     _handleTriggerClick(e) {
       // Stop propagation here so we don't confuse gr-autocomplete, which
       // listens for taps on body to try to determine when it's blurred.
       e.stopPropagation();
       this.$.autocomplete.focus();
-    },
+    }
 
     setText(text) {
       this.$.autocomplete.setText(text);
-    },
+    }
 
     clear() {
       this.setText('');
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLabeledAutocomplete.is, GrLabeledAutocomplete);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
index b257746..7e8f73f 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-labeled-autocomplete</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
index 5ea5dca..defc6bd 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
@@ -20,21 +20,26 @@
   const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
   const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
 
-  Polymer({
-    is: 'gr-lib-loader',
+  /** @extends Polymer.Element */
+  class GrLibLoader extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-lib-loader'; }
 
-    properties: {
-      _hljsState: {
-        type: Object,
+    static get properties() {
+      return {
+        _hljsState: {
+          type: Object,
 
-        // NOTE: intended singleton.
-        value: {
-          configured: false,
-          loading: false,
-          callbacks: [],
+          // NOTE: intended singleton.
+          value: {
+            configured: false,
+            loading: false,
+            callbacks: [],
+          },
         },
-      },
-    },
+      };
+    }
 
     /**
      * Get the HLJS library. Returns a promise that resolves with a reference to
@@ -55,12 +60,13 @@
         if (!this._hljsState.loading) {
           this._hljsState.loading = true;
           this._loadScript(this._getHLJSUrl())
-              .then(this._onHLJSLibLoaded.bind(this)).catch(reject);
+              .then(this._onHLJSLibLoaded.bind(this))
+              .catch(reject);
         }
 
         this._hljsState.callbacks.push(resolve);
       });
-    },
+    }
 
     /**
      * Loads the dark theme document. Returns a promise that resolves with a
@@ -73,12 +79,15 @@
       return new Promise((resolve, reject) => {
         (this.importHref || Polymer.importHref)(
             this._getLibRoot() + DARK_THEME_PATH, () => {
-              const module = document.createElement('style', 'custom-style');
+              const module = document.createElement('style');
               module.setAttribute('include', 'dark-theme');
-              resolve(module);
+              const cs = document.createElement('custom-style');
+              cs.appendChild(module);
+
+              resolve(cs);
             });
       });
-    },
+    }
 
     /**
      * Execute callbacks awaiting the HLJS lib load.
@@ -93,7 +102,7 @@
         cb(lib);
       }
       this._hljsState.callbacks = [];
-    },
+    }
 
     /**
      * Get the HLJS library, assuming it has been loaded. Configure the library
@@ -109,7 +118,7 @@
         lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
       }
       return lib;
-    },
+    }
 
     /**
      * Get the resource path used to load the application. If the application
@@ -122,7 +131,7 @@
         return window.STATIC_RESOURCE_PATH + '/';
       }
       return '/';
-    },
+    }
 
     /**
      * Load and execute a JS file from the lib root.
@@ -145,12 +154,14 @@
         script.onerror = reject;
         Polymer.dom(document.head).appendChild(script);
       });
-    },
+    }
 
     _getHLJSUrl() {
       const root = this._getLibRoot();
       if (!root) { return null; }
       return root + HLJS_PATH;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLibLoader.is, GrLibLoader);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
index 10d1608..832a558 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-lib-loader</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
index 048e4f5..ee032f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -17,57 +17,63 @@
 (function() {
   'use strict';
 
-  /*
+  /**
    * The gr-limited-text element is for displaying text with a maximum length
    * (in number of characters) to display. If the length of the text exceeds the
    * configured limit, then an ellipsis indicates that the text was truncated
    * and a tooltip containing the full text is enabled.
+   *
+   * @appliesMixin Gerrit.TooltipMixin
+   * @extends Polymer.Element
    */
+  class GrLimitedText extends Polymer.mixinBehaviors( [
+    Gerrit.TooltipBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-limited-text'; }
 
-  Polymer({
-    is: 'gr-limited-text',
-
-    properties: {
+    static get properties() {
+      return {
       /** The un-truncated text to display. */
-      text: String,
+        text: String,
 
-      /** The maximum length for the text to display before truncating. */
-      limit: {
-        type: Number,
-        value: null,
-      },
+        /** The maximum length for the text to display before truncating. */
+        limit: {
+          type: Number,
+          value: null,
+        },
 
-      /** Boolean property used by Gerrit.TooltipBehavior. */
-      hasTooltip: {
-        type: Boolean,
-        value: false,
-      },
+        /** Boolean property used by Gerrit.TooltipBehavior. */
+        hasTooltip: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * Disable the tooltip.
-       * When set to true, will not show tooltip even text is over limit
-       */
-      disableTooltip: {
-        type: Boolean,
-        value: false,
-      },
+        /**
+         * Disable the tooltip.
+         * When set to true, will not show tooltip even text is over limit
+         */
+        disableTooltip: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**
-       * The maximum number of characters to display in the tooltop.
-       */
-      tooltipLimit: {
-        type: Number,
-        value: 1024,
-      },
-    },
+        /**
+         * The maximum number of characters to display in the tooltop.
+         */
+        tooltipLimit: {
+          type: Number,
+          value: 1024,
+        },
+      };
+    }
 
-    observers: [
-      '_updateTitle(text, limit, tooltipLimit)',
-    ],
-
-    behaviors: [
-      Gerrit.TooltipBehavior,
-    ],
+    static get observers() {
+      return [
+        '_updateTitle(text, limit, tooltipLimit)',
+      ];
+    }
 
     /**
      * The text or limit have changed. Recompute whether a tooltip needs to be
@@ -85,13 +91,15 @@
       } else {
         this.removeAttribute('title');
       }
-    },
+    }
 
     _computeDisplayText(text, limit) {
       if (!!limit && !!text && text.length > limit) {
         return text.substr(0, limit - 1) + '…';
       }
       return text;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLimitedText.is, GrLimitedText);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
index 7946bb6..3ba30d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-limited-text</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index 33a9c25..ccab685 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -17,41 +17,49 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-linked-chip',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrLinkedChip extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-linked-chip'; }
 
-    properties: {
-      href: String,
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      removable: {
-        type: Boolean,
-        value: false,
-      },
-      text: String,
-      transparentBackground: {
-        type: Boolean,
-        value: false,
-      },
+    static get properties() {
+      return {
+        href: String,
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        removable: {
+          type: Boolean,
+          value: false,
+        },
+        text: String,
+        transparentBackground: {
+          type: Boolean,
+          value: false,
+        },
 
-      /**  If provided, sets the maximum length of the content. */
-      limit: Number,
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
+        /**  If provided, sets the maximum length of the content. */
+        limit: Number,
+      };
+    }
 
     _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
-    },
+    }
 
     _handleRemoveTap(e) {
       e.preventDefault();
       this.fire('remove');
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLinkedChip.is, GrLinkedChip);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
index 22a2eaf..733e897 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-chip</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index 6eb9f46..bbe525e 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -17,31 +17,38 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-linked-text',
+  /** @extends Polymer.Element */
+  class GrLinkedText extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-linked-text'; }
 
-    properties: {
-      removeZeroWidthSpace: Boolean,
-      content: {
-        type: String,
-        observer: '_contentChanged',
-      },
-      pre: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      config: Object,
-    },
+    static get properties() {
+      return {
+        removeZeroWidthSpace: Boolean,
+        content: {
+          type: String,
+          observer: '_contentChanged',
+        },
+        pre: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        disabled: {
+          type: Boolean,
+          value: false,
+          reflectToAttribute: true,
+        },
+        config: Object,
+      };
+    }
 
-    observers: [
-      '_contentOrConfigChanged(content, config)',
-    ],
+    static get observers() {
+      return [
+        '_contentOrConfigChanged(content, config)',
+      ];
+    }
 
     _contentChanged(content) {
       // In the case where the config may not be set (perhaps due to the
@@ -49,7 +56,7 @@
       // prevent waiting on the config to display the text.
       if (this.config != null) { return; }
       this.$.output.textContent = content;
-    },
+    }
 
     /**
      * Because either the source text or the linkification config has changed,
@@ -75,7 +82,7 @@
         anchor.setAttribute('target', '_blank');
         anchor.setAttribute('rel', 'noopener');
       });
-    },
+    }
 
     /**
      * This method is called when the GrLikTextParser emits a partial result
@@ -102,6 +109,8 @@
       } else if (fragment) {
         output.appendChild(fragment);
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrLinkedText.is, GrLinkedText);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index f01a75c..e9477bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-linked-text</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index 1aa707f..eb2b0d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -29,6 +29,7 @@
    * in the text as well as custom links if any are specified in the linkConfig
    * parameter.
    *
+   * @constructor
    * @param {Object|null|undefined} linkConfig Comment links as specified by the
    *     commentlinks field on a project config.
    * @param {Function} callback The callback to be fired when an intermediate
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 6840e97..8913cd8 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -19,31 +19,41 @@
 
   const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
-  Polymer({
-    is: 'gr-list-view',
+  /**
+   * @appliesMixin Gerrit.BaseUrlMixin
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrListView extends Polymer.mixinBehaviors( [
+    Gerrit.BaseUrlBehavior,
+    Gerrit.FireBehavior,
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-list-view'; }
 
-    properties: {
-      createNew: Boolean,
-      items: Array,
-      itemsPerPage: Number,
-      filter: {
-        type: String,
-        observer: '_filterChanged',
-      },
-      offset: Number,
-      loading: Boolean,
-      path: String,
-    },
+    static get properties() {
+      return {
+        createNew: Boolean,
+        items: Array,
+        itemsPerPage: Number,
+        filter: {
+          type: String,
+          observer: '_filterChanged',
+        },
+        offset: Number,
+        loading: Boolean,
+        path: String,
+      };
+    }
 
-    behaviors: [
-      Gerrit.BaseUrlBehavior,
-      Gerrit.FireBehavior,
-      Gerrit.URLEncodingBehavior,
-    ],
-
+    /** @override */
     detached() {
+      super.detached();
       this.cancelDebouncer('reload');
-    },
+    }
 
     _filterChanged(newFilter, oldFilter) {
       if (!newFilter && !oldFilter) {
@@ -51,7 +61,7 @@
       }
 
       this._debounceReload(newFilter);
-    },
+    }
 
     _debounceReload(filter) {
       this.debounce('reload', () => {
@@ -61,11 +71,11 @@
         }
         page.show(this.path);
       }, REQUEST_DEBOUNCE_INTERVAL_MS);
-    },
+    }
 
     _createNewItem() {
       this.fire('create-clicked');
-    },
+    }
 
     _computeNavLink(offset, direction, itemsPerPage, filter, path) {
       // Offset could be a string when passed from the router.
@@ -79,15 +89,15 @@
         href += ',' + newOffset;
       }
       return href;
-    },
+    }
 
     _computeCreateClass(createNew) {
       return createNew ? 'show' : '';
-    },
+    }
 
     _hidePrevArrow(loading, offset) {
       return loading || offset === 0;
-    },
+    }
 
     _hideNextArrow(loading, items) {
       if (loading || !items || !items.length) {
@@ -95,13 +105,15 @@
       }
       const lastPage = items.length < this.itemsPerPage + 1;
       return lastPage;
-    },
+    }
 
     // TODO: fix offset (including itemsPerPage)
     // to either support a decimal or make it go to the nearest
     // whole number (e.g 3).
     _computePage(offset, itemsPerPage) {
       return offset / itemsPerPage + 1;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrListView.is, GrListView);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
index ea1dcbb..b8f12ad 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-list-view</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 <script src="/bower_components/page/page.js"></script>
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -126,11 +126,13 @@
     });
 
     test('createNew link appears correctly', () => {
-      assert.isFalse(element.$$('#createNewContainer').classList
+      assert.isFalse(element.shadowRoot
+          .querySelector('#createNewContainer').classList
           .contains('show'));
       element.createNew = true;
       flushAsynchronousOperations();
-      assert.isTrue(element.$$('#createNewContainer').classList
+      assert.isTrue(element.shadowRoot
+          .querySelector('#createNewContainer').classList
           .contains('show'));
     });
 
@@ -139,7 +141,7 @@
       element.addEventListener('create-clicked', clickHandler);
       element.createNew = true;
       flushAsynchronousOperations();
-      MockInteractions.tap(element.$$('#createNew'));
+      MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
       assert.isTrue(clickHandler.called);
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index 2b4b982..1afd1c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -25,7 +25,8 @@
     <style include="shared-styles">
       :host {
         background: var(--dialog-background-color);
-        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
       }
 
       @media screen and (max-width: 50em) {
@@ -35,6 +36,8 @@
           position: fixed;
           right: 0;
           top: 0;
+          border-radius: 0;
+          box-shadow: none;
         }
       }
     </style>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index 8623458..5dee2fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -21,9 +21,17 @@
   const AWAIT_STEP = 5;
   const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
 
-  Polymer({
-    is: 'gr-overlay',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrOverlay extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Polymer.IronOverlayBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-overlay'; }
     /**
      * Fired when a fullscreen overlay is closed
      *
@@ -36,22 +44,23 @@
      * @event fullscreen-overlay-opened
      */
 
-    properties: {
-      _fullScreenOpen: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        _fullScreenOpen: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Polymer.IronOverlayBehavior,
-    ],
-
-    listeners: {
-      'iron-overlay-closed': '_close',
-      'iron-overlay-cancelled': '_close',
-    },
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('iron-overlay-closed',
+          () => this._close());
+      this.addEventListener('iron-overlay-cancelled',
+          () => this._close());
+    }
 
     open(...args) {
       return new Promise(resolve => {
@@ -62,18 +71,18 @@
         }
         this._awaitOpen(resolve);
       });
-    },
+    }
 
     _isMobile() {
       return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-    },
+    }
 
     _close() {
       if (this._fullScreenOpen) {
         this.fire('fullscreen-overlay-closed');
         this._fullScreenOpen = false;
       }
-    },
+    }
 
     /**
      * Override the focus stops that iron-overlay-behavior tries to find.
@@ -81,7 +90,7 @@
     setFocusStops(stops) {
       this.__firstFocusableNode = stops.start;
       this.__lastFocusableNode = stops.end;
-    },
+    }
 
     /**
      * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
@@ -95,14 +104,22 @@
             fn.call(this);
           } else if (iters++ < AWAIT_MAX_ITERS) {
             step.call(this);
+          } else {
+            // TODO(crbug.com/gerrit/10774): Once this is confirmed as the root
+            // cause of the bug, fix it by either making sure to resolve the fn
+            // function or find a better way to listen on the overlay being
+            // shown.
+            console.warn('gr-overlay _awaitOpen failed to resolve');
           }
         }, AWAIT_STEP);
       };
       step.call(this);
-    },
+    }
 
     _id() {
       return this.getAttribute('id') || 'global';
-    },
-  });
+    }
+  }
+
+  customElements.define(GrOverlay.is, GrOverlay);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
index 08b7497..659823f 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-overlay</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
index 2e05607..ac876c4 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
@@ -17,20 +17,29 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-page-nav',
+  /** @extends Polymer.Element */
+  class GrPageNav extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-page-nav'; }
 
-    properties: {
-      _headerHeight: Number,
-    },
+    static get properties() {
+      return {
+        _headerHeight: Number,
+      };
+    }
 
+    /** @override */
     attached() {
+      super.attached();
       this.listen(window, 'scroll', '_handleBodyScroll');
-    },
+    }
 
+    /** @override */
     detached() {
+      super.detached();
       this.unlisten(window, 'scroll', '_handleBodyScroll');
-    },
+    }
 
     _handleBodyScroll() {
       if (this._headerHeight === undefined) {
@@ -45,20 +54,22 @@
 
       this.$.nav.classList.toggle('pinned',
           this._getScrollY() >= this._headerHeight);
-    },
+    }
 
     /* Functions used for test purposes */
     _getOffsetParent(element) {
       if (!element || !element.offsetParent) { return ''; }
       return element.offsetParent;
-    },
+    }
 
     _getOffsetTop(element) {
       return element.offsetTop;
-    },
+    }
 
     _getScrollY() {
       return window.scrollY;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrPageNav.is, GrPageNav);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
index b384b47..663d3bd 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-page-nav</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/page/page.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
index e2298c3..18e2596 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
@@ -20,47 +20,57 @@
   const SUGGESTIONS_LIMIT = 15;
   const REF_PREFIX = 'refs/heads/';
 
-  Polymer({
-    is: 'gr-repo-branch-picker',
+  /**
+   * @appliesMixin Gerrit.URLEncodingMixin
+   * @extends Polymer.Element
+   */
+  class GrRepoBranchPicker extends Polymer.mixinBehaviors( [
+    Gerrit.URLEncodingBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-repo-branch-picker'; }
 
-    properties: {
-      repo: {
-        type: String,
-        notify: true,
-        observer: '_repoChanged',
-      },
-      branch: {
-        type: String,
-        notify: true,
-      },
-      _branchDisabled: Boolean,
-      _query: {
-        type: Function,
-        value() {
-          return this._getRepoBranchesSuggestions.bind(this);
+    static get properties() {
+      return {
+        repo: {
+          type: String,
+          notify: true,
+          observer: '_repoChanged',
         },
-      },
-      _repoQuery: {
-        type: Function,
-        value() {
-          return this._getRepoSuggestions.bind(this);
+        branch: {
+          type: String,
+          notify: true,
         },
-      },
-    },
+        _branchDisabled: Boolean,
+        _query: {
+          type: Function,
+          value() {
+            return this._getRepoBranchesSuggestions.bind(this);
+          },
+        },
+        _repoQuery: {
+          type: Function,
+          value() {
+            return this._getRepoSuggestions.bind(this);
+          },
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.URLEncodingBehavior,
-    ],
-
+    /** @override */
     attached() {
+      super.attached();
       if (this.repo) {
         this.$.repoInput.setText(this.repo);
       }
-    },
+    }
 
+    /** @override */
     ready() {
+      super.ready();
       this._branchDisabled = !this.repo;
-    },
+    }
 
     _getRepoBranchesSuggestions(input) {
       if (!this.repo) { return Promise.resolve([]); }
@@ -69,19 +79,21 @@
       }
       return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
           .then(this._branchResponseToSuggestions.bind(this));
-    },
+    }
 
     _getRepoSuggestions(input) {
       return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
           .then(this._repoResponseToSuggestions.bind(this));
-    },
+    }
 
     _repoResponseToSuggestions(res) {
-      return res.map(repo => ({
-        name: repo.name,
-        value: this.singleDecodeURL(repo.id),
-      }));
-    },
+      return res.map(repo => {
+        return {
+          name: repo.name,
+          value: this.singleDecodeURL(repo.id),
+        };
+      });
+    }
 
     _branchResponseToSuggestions(res) {
       return Object.keys(res).map(key => {
@@ -91,19 +103,21 @@
         }
         return {name: branch, value: branch};
       });
-    },
+    }
 
     _repoCommitted(e) {
       this.repo = e.detail.value;
-    },
+    }
 
     _branchCommitted(e) {
       this.branch = e.detail.value;
-    },
+    }
 
     _repoChanged() {
       this.$.branchInput.clear();
       this._branchDisabled = !this.repo;
-    },
-  });
+    }
+  }
+
+  customElements.define(GrRepoBranchPicker.is, GrRepoBranchPicker);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
index 1ed9151..3bad2a9 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-repo-branch-picker</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -126,8 +126,9 @@
             });
       });
 
-      test('does not query when repo is unset', () => {
-        return element._getRepoBranchesSuggestions('')
+      test('does not query when repo is unset', done => {
+        element
+            ._getRepoBranchesSuggestions('')
             .then(() => {
               assert.isFalse(element.$.restAPI.getRepoBranches.called);
               element.repo = 'gerrit';
@@ -135,6 +136,7 @@
             })
             .then(() => {
               assert.isTrue(element.$.restAPI.getRepoBranches.called);
+              done();
             });
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
index 2ff5f85..2978193 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
@@ -20,22 +20,90 @@
   // Prevent redefinition.
   if (window.Gerrit.Auth) { return; }
 
+  const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
   const MAX_GET_TOKEN_RETRIES = 2;
 
-  Gerrit.Auth = {
-    TYPE: {
-      XSRF_TOKEN: 'xsrf_token',
-      ACCESS_TOKEN: 'access_token',
-    },
+  /**
+   * Auth class.
+   *
+   * Gerrit.Auth is an instance of this class.
+   */
+  class Auth {
+    constructor() {
+      this._type = null;
+      this._cachedTokenPromise = null;
+      this._defaultOptions = {};
+      this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+      this._status = Auth.STATUS.UNDETERMINED;
+      this._authCheckPromise = null;
+      this._last_auth_check_time = Date.now();
+    }
 
-    _type: null,
-    _cachedTokenPromise: null,
-    _defaultOptions: {},
-    _retriesLeft: MAX_GET_TOKEN_RETRIES,
+    get baseUrl() {
+      return Gerrit.BaseUrlBehavior.getBaseUrl();
+    }
+
+    /**
+     * Returns if user is authed or not.
+     *
+     * @returns {!Promise<boolean>}
+     */
+    authCheck() {
+      if (!this._authCheckPromise ||
+        (Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS)
+      ) {
+        // Refetch after last check expired
+        this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+        this._last_auth_check_time = Date.now();
+      }
+
+      return this._authCheckPromise.then(res => {
+        // auth-check will return 204 if authed
+        // treat the rest as unauthed
+        if (res.status === 204) {
+          this._setStatus(Auth.STATUS.AUTHED);
+          return true;
+        } else {
+          this._setStatus(Auth.STATUS.NOT_AUTHED);
+          return false;
+        }
+      }).catch(e => {
+        this._setStatus(Auth.STATUS.ERROR);
+        // Reset _authCheckPromise to avoid caching the failed promise
+        this._authCheckPromise = null;
+        return false;
+      });
+    }
+
+    clearCache() {
+      this._authCheckPromise = null;
+    }
+
+    /**
+     * @param {Auth.STATUS} status
+     */
+    _setStatus(status) {
+      if (this._status === status) return;
+
+      if (this._status === Auth.STATUS.AUTHED) {
+        Gerrit.emit('auth-error', {
+          message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
+        });
+      }
+      this._status = status;
+    }
+
+    get status() {
+      return this._status;
+    }
+
+    get isAuthed() {
+      return this._status === Auth.STATUS.AUTHED;
+    }
 
     _getToken() {
       return Promise.resolve(this._cachedTokenPromise);
-    },
+    }
 
     /**
      * Enable cross-domain authentication using OAuth access token.
@@ -51,7 +119,7 @@
     setup(getToken, defaultOptions) {
       this._retriesLeft = MAX_GET_TOKEN_RETRIES;
       if (getToken) {
-        this._type = Gerrit.Auth.TYPE.ACCESS_TOKEN;
+        this._type = Auth.TYPE.ACCESS_TOKEN;
         this._cachedTokenPromise = null;
         this._getToken = getToken;
       }
@@ -61,7 +129,7 @@
           this._defaultOptions[p] = defaultOptions[p];
         }
       }
-    },
+    }
 
     /**
      * Perform network fetch with authentication.
@@ -74,14 +142,15 @@
       const options = Object.assign({
         headers: new Headers(),
       }, this._defaultOptions, opt_options);
-      if (this._type === Gerrit.Auth.TYPE.ACCESS_TOKEN) {
+      if (this._type === Auth.TYPE.ACCESS_TOKEN) {
         return this._getAccessToken().then(
-            accessToken => this._fetchWithAccessToken(url, options, accessToken)
+            accessToken =>
+              this._fetchWithAccessToken(url, options, accessToken)
         );
       } else {
         return this._fetchWithXsrfToken(url, options);
       }
-    },
+    }
 
     _getCookie(name) {
       const key = name + '=';
@@ -94,7 +163,7 @@
         }
       });
       return result;
-    },
+    }
 
     _isTokenValid(token) {
       if (!token) { return false; }
@@ -104,7 +173,7 @@
       if (Date.now() >= expiration.getTime()) { return false; }
 
       return true;
-    },
+    }
 
     _fetchWithXsrfToken(url, options) {
       if (options.method && options.method !== 'GET') {
@@ -115,7 +184,7 @@
       }
       options.credentials = 'same-origin';
       return fetch(url, options);
-    },
+    }
 
     /**
      * @return {!Promise<string>}
@@ -137,14 +206,14 @@
         // Fall back to anonymous access.
         return null;
       });
-    },
+    }
 
     _fetchWithAccessToken(url, options, accessToken) {
       const params = [];
 
       if (accessToken) {
         params.push(`access_token=${accessToken}`);
-        const baseUrl = Gerrit.BaseUrlBehavior.getBaseUrl();
+        const baseUrl = this.baseUrl;
         const pathname = baseUrl ?
           url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
         if (!pathname.startsWith('/a/')) {
@@ -179,8 +248,25 @@
         url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
       }
       return fetch(url, options);
-    },
+    }
+  }
+
+  Auth.TYPE = {
+    XSRF_TOKEN: 'xsrf_token',
+    ACCESS_TOKEN: 'access_token',
   };
 
-  window.Gerrit.Auth = Gerrit.Auth;
+  /** @enum {number} */
+  Auth.STATUS = {
+    UNDETERMINED: 0,
+    AUTHED: 1,
+    NOT_AUTHED: 2,
+    ERROR: 3,
+  };
+
+  Auth.CREDS_EXPIRED_MSG = 'Credentails expired.';
+
+  // TODO(taoalpha): this whole thing should be moved to a service
+  window.Auth = Auth;
+  Gerrit.Auth = new Auth();
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
index dc07d0f..cbaf49d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-auth</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -35,7 +35,6 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
       auth = Gerrit.Auth;
     });
 
@@ -43,29 +42,222 @@
       sandbox.restore();
     });
 
-    suite('default (xsrf token header)', () => {
-      test('GET', () => {
-        return auth.fetch('/url', {bar: 'bar'}).then(() => {
-          const [url, options] = fetch.lastCall.args;
-          assert.equal(url, '/url');
-          assert.equal(options.credentials, 'same-origin');
+    suite('Auth class methods', () => {
+      let fakeFetch;
+      setup(() => {
+        auth = new Auth();
+        fakeFetch = sandbox.stub(window, 'fetch');
+      });
+
+      test('auth-check returns 403', done => {
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          done();
         });
       });
 
-      test('POST', () => {
+      test('auth-check returns 204', done => {
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed => {
+          assert.isTrue(authed);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+
+      test('auth-check returns 502', done => {
+        fakeFetch.returns(Promise.resolve({status: 502}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          done();
+        });
+      });
+
+      test('auth-check failed', done => {
+        fakeFetch.returns(Promise.reject(new Error('random error')));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.ERROR);
+          done();
+        });
+      });
+    });
+
+    suite('cache and events behaivor', () => {
+      let fakeFetch;
+      let clock;
+      setup(() => {
+        auth = new Auth();
+        clock = sinon.useFakeTimers();
+        fakeFetch = sandbox.stub(window, 'fetch');
+      });
+
+      test('cache auth-check result', done => {
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          fakeFetch.returns(Promise.resolve({status: 204}));
+          auth.authCheck().then(authed2 => {
+            assert.isFalse(authed);
+            assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+            done();
+          });
+        });
+      });
+
+      test('clearCache should refetch auth-check result', done => {
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          fakeFetch.returns(Promise.resolve({status: 204}));
+          auth.clearCache();
+          auth.authCheck().then(authed2 => {
+            assert.isTrue(authed2);
+            assert.equal(auth.status, Auth.STATUS.AUTHED);
+            done();
+          });
+        });
+      });
+
+      test('cache expired on auth-check after certain time', done => {
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          clock.tick(1000 * 10000);
+          fakeFetch.returns(Promise.resolve({status: 204}));
+          auth.authCheck().then(authed2 => {
+            assert.isTrue(authed2);
+            assert.equal(auth.status, Auth.STATUS.AUTHED);
+            done();
+          });
+        });
+      });
+
+      test('no cache if auth-check failed', done => {
+        fakeFetch.returns(Promise.reject(new Error('random error')));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.ERROR);
+          assert.equal(fakeFetch.callCount, 1);
+          auth.authCheck().then(() => {
+            assert.equal(fakeFetch.callCount, 2);
+            done();
+          });
+        });
+      });
+
+      test('fire event when switch from authed to unauthed', done => {
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed => {
+          assert.isTrue(authed);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          clock.tick(1000 * 10000);
+          fakeFetch.returns(Promise.resolve({status: 403}));
+          const emitStub = sinon.stub();
+          Gerrit.emit = emitStub;
+          auth.authCheck().then(authed2 => {
+            assert.isFalse(authed2);
+            assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+            assert.isTrue(emitStub.called);
+            done();
+          });
+        });
+      });
+
+      test('fire event when switch from authed to error', done => {
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed => {
+          assert.isTrue(authed);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          clock.tick(1000 * 10000);
+          fakeFetch.returns(Promise.reject(new Error('random error')));
+          const emitStub = sinon.stub();
+          Gerrit.emit = emitStub;
+          auth.authCheck().then(authed2 => {
+            assert.isFalse(authed2);
+            assert.isTrue(emitStub.called);
+            assert.equal(auth.status, Auth.STATUS.ERROR);
+            done();
+          });
+        });
+      });
+
+      test('no event from non-authed to other status', done => {
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          clock.tick(1000 * 10000);
+          fakeFetch.returns(Promise.resolve({status: 204}));
+          const emitStub = sinon.stub();
+          Gerrit.emit = emitStub;
+          auth.authCheck().then(authed2 => {
+            assert.isTrue(authed2);
+            assert.isFalse(emitStub.called);
+            assert.equal(auth.status, Auth.STATUS.AUTHED);
+            done();
+          });
+        });
+      });
+
+      test('no event from non-authed to other status', done => {
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        auth.authCheck().then(authed => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          clock.tick(1000 * 10000);
+          fakeFetch.returns(Promise.reject(new Error('random error')));
+          const emitStub = sinon.stub();
+          Gerrit.emit = emitStub;
+          auth.authCheck().then(authed2 => {
+            assert.isFalse(authed2);
+            assert.isFalse(emitStub.called);
+            assert.equal(auth.status, Auth.STATUS.ERROR);
+            done();
+          });
+        });
+      });
+    });
+
+    suite('default (xsrf token header)', () => {
+      setup(() => {
+        sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+      });
+
+      test('GET', done => {
+        auth.fetch('/url', {bar: 'bar'}).then(() => {
+          const [url, options] = fetch.lastCall.args;
+          assert.equal(url, '/url');
+          assert.equal(options.credentials, 'same-origin');
+          done();
+        });
+      });
+
+      test('POST', done => {
         sandbox.stub(auth, '_getCookie')
             .withArgs('XSRF_TOKEN')
             .returns('foobar');
-        return auth.fetch('/url', {method: 'POST'}).then(() => {
+        auth.fetch('/url', {method: 'POST'}).then(() => {
           const [url, options] = fetch.lastCall.args;
           assert.equal(url, '/url');
           assert.equal(options.credentials, 'same-origin');
           assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
+          done();
         });
       });
     });
 
     suite('cors (access token)', () => {
+      setup(() => {
+        sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+      });
+
       let getToken;
 
       const makeToken = opt_accessToken => {
@@ -81,62 +273,72 @@
         auth.setup(getToken);
       });
 
-      test('base url support', () => {
+      test('base url support', done => {
         const baseUrl = 'http://foo';
         sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
-        return auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
+        auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
           const [url] = fetch.lastCall.args;
           assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+          done();
         });
       });
 
-      test('fetch not signed in', () => {
+      test('fetch not signed in', done => {
         getToken.returns(Promise.resolve());
-        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+        auth.fetch('/url', {bar: 'bar'}).then(() => {
           const [url, options] = fetch.lastCall.args;
           assert.equal(url, '/url');
           assert.equal(options.bar, 'bar');
           assert.equal(Object.keys(options.headers).length, 0);
+          done();
         });
       });
 
-      test('fetch signed in', () => {
-        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+      test('fetch signed in', done => {
+        auth.fetch('/url', {bar: 'bar'}).then(() => {
           const [url, options] = fetch.lastCall.args;
           assert.equal(url, '/a/url?access_token=zbaz');
           assert.equal(options.bar, 'bar');
+          done();
         });
       });
 
-      test('getToken calls are cached', () => {
-        return Promise.all([
+      test('getToken calls are cached', done => {
+        Promise.all([
           auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
           assert.equal(getToken.callCount, 1);
+          done();
         });
       });
 
-      test('getToken refreshes token', () => {
+      test('getToken refreshes token', done => {
         sandbox.stub(auth, '_isTokenValid');
         auth._isTokenValid
             .onFirstCall().returns(true)
-            .onSecondCall().returns(false)
-            .onThirdCall().returns(true);
-        return auth.fetch('/url-one').then(() => {
-          getToken.returns(Promise.resolve(makeToken('bzzbb')));
-          return auth.fetch('/url-two');
-        }).then(() => {
-          const [[firstUrl], [secondUrl]] = fetch.args;
-          assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
-          assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
-        });
+            .onSecondCall()
+            .returns(false)
+            .onThirdCall()
+            .returns(true);
+        auth.fetch('/url-one')
+            .then(() => {
+              getToken.returns(Promise.resolve(makeToken('bzzbb')));
+              return auth.fetch('/url-two');
+            })
+            .then(() => {
+              const [[firstUrl], [secondUrl]] = fetch.args;
+              assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+              assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
+              done();
+            });
       });
 
-      test('signed in token error falls back to anonymous', () => {
+      test('signed in token error falls back to anonymous', done => {
         getToken.returns(Promise.resolve('rubbish'));
-        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+        auth.fetch('/url', {bar: 'bar'}).then(() => {
           const [url, options] = fetch.lastCall.args;
           assert.equal(url, '/url');
           assert.equal(options.bar, 'bar');
+          done();
         });
       });
 
@@ -154,12 +356,12 @@
         }));
       });
 
-      test('HTTP PUT with content type', () => {
+      test('HTTP PUT with content type', done => {
         const originalOptions = {
           method: 'PUT',
           headers: new Headers({'Content-Type': 'mail/pigeon'}),
         };
-        return auth.fetch('/url', originalOptions).then(() => {
+        auth.fetch('/url', originalOptions).then(() => {
           assert.isTrue(getToken.called);
           const [url, options] = fetch.lastCall.args;
           assert.include(url, '$ct=mail%2Fpigeon');
@@ -167,14 +369,15 @@
           assert.include(url, 'access_token=zbaz');
           assert.equal(options.method, 'POST');
           assert.equal(options.headers.get('Content-Type'), 'text/plain');
+          done();
         });
       });
 
-      test('HTTP PUT without content type', () => {
+      test('HTTP PUT without content type', done => {
         const originalOptions = {
           method: 'PUT',
         };
-        return auth.fetch('/url', originalOptions).then(() => {
+        auth.fetch('/url', originalOptions).then(() => {
           assert.isTrue(getToken.called);
           const [url, options] = fetch.lastCall.args;
           assert.include(url, '$ct=text%2Fplain');
@@ -182,6 +385,7 @@
           assert.include(url, 'access_token=zbaz');
           assert.equal(options.method, 'POST');
           assert.equal(options.headers.get('Content-Type'), 'text/plain');
+          done();
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
index c20cdd7..7022d23 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
@@ -23,6 +23,7 @@
   // Limit cache size because /change/detail responses may be large.
   const MAX_CACHE_SIZE = 30;
 
+  /** @constructor */
   function GrEtagDecorator() {
     this._etags = new Map();
     this._payloadCache = new Map();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
index 76c8c2c..623ea18 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-etag-decorator</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 0746677..4aefe46 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -39,16 +39,22 @@
   const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
       '/revisions/*';
 
-  Polymer({
-    is: 'gr-rest-api-interface',
-
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.PathListBehavior,
-      Gerrit.PatchSetBehavior,
-      Gerrit.RESTClientBehavior,
-    ],
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.PathListMixin
+   * @appliesMixin Gerrit.PatchSetMixin
+   * @appliesMixin Gerrit.RESTClientMixin
+   * @extends Polymer.Element
+   */
+  class GrRestApiInterface extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.PathListBehavior,
+    Gerrit.PatchSetBehavior,
+    Gerrit.RESTClientBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-rest-api-interface'; }
     /**
      * Fired when an server error occurs.
      *
@@ -62,96 +68,65 @@
      */
 
     /**
-     * Fired when credentials were rejected by server (e.g. expired).
-     *
-     * @event auth-error
-     */
-
-    /**
      * Fired after an RPC completes.
      *
      * @event rpc-log
      */
 
-    properties: {
-      _cache: {
-        type: Object,
-        value: new SiteBasedCache(), // Shared across instances.
-      },
-      _credentialCheck: {
-        type: Object,
-        value: {checking: false}, // Shared across instances.
-      },
-      _sharedFetchPromises: {
-        type: Object,
-        value: new FetchPromisesCache(), // Shared across instances.
-      },
-      _pendingRequests: {
-        type: Object,
-        value: {}, // Intentional to share the object across instances.
-      },
-      _etags: {
-        type: Object,
-        value: new GrEtagDecorator(), // Share across instances.
-      },
-      /**
-       * Used to maintain a mapping of changeNums to project names.
-       */
-      _projectLookup: {
-        type: Object,
-        value: {}, // Intentional to share the object across instances.
-      },
-      _auth: {
-        type: Object,
-        value: Gerrit.Auth, // Share across instances.
-      },
-    },
+    constructor() {
+      super();
+      this.JSON_PREFIX = JSON_PREFIX;
+    }
 
-    JSON_PREFIX,
+    static get properties() {
+      return {
+        _cache: {
+          type: Object,
+          value: new SiteBasedCache(), // Shared across instances.
+        },
+        _sharedFetchPromises: {
+          type: Object,
+          value: new FetchPromisesCache(), // Shared across instances.
+        },
+        _pendingRequests: {
+          type: Object,
+          value: {}, // Intentional to share the object across instances.
+        },
+        _etags: {
+          type: Object,
+          value: new GrEtagDecorator(), // Share across instances.
+        },
+        /**
+         * Used to maintain a mapping of changeNums to project names.
+         */
+        _projectLookup: {
+          type: Object,
+          value: {}, // Intentional to share the object across instances.
+        },
+      };
+    }
 
+    /** @override */
     created() {
-      /* Polymer 1 and Polymer 2 have slightly different lifecycle.
-      * Differences are not very well documented (see
-      * https://github.com/Polymer/old-docs-site/issues/2322).
-      * In Polymer 1, created() is called when properties values is not set
-      * and ready() is always called later, even if element is not added
-      * to a DOM. I.e. in Polymer 1 _cache and other properties are undefined,
-      * while in Polymer 2 they are set to default values.
-      * In Polymer 2, created() is called after properties values set and
-      * ready() is called only after element is attached to a DOM.
-      * There are several places in the code, where element is created with
-      * document.createElement('gr-rest-api-interface') and is not added
-      * to a DOM.
-      * In such cases, Polymer 1 calls both created() and ready() methods,
-      * but Polymer 2 calls only created() method.
-      * To workaround these differences, we should try to create _restApiHelper
-      * in both methods.
-      */
-      //
-
+      super.created();
+      this._auth = Gerrit.Auth;
       this._initRestApiHelper();
-    },
-
-    ready() {
-      // See comments in created()
-      this._initRestApiHelper();
-    },
+    }
 
     _initRestApiHelper() {
       if (this._restApiHelper) {
         return;
       }
-      if (this._cache && this._auth && this._sharedFetchPromises
-          && this._credentialCheck) {
+      if (this._cache && this._auth && this._sharedFetchPromises) {
         this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
-            this._sharedFetchPromises, this._credentialCheck, this);
+            this._sharedFetchPromises, this);
       }
-    },
+    }
 
     _fetchSharedCacheURL(req) {
       // Cache is shared across instances
       return this._restApiHelper.fetchCacheURL(req);
-    },
+    }
 
     /**
      * @param {!Object} response
@@ -159,7 +134,7 @@
      */
     getResponseObject(response) {
       return this._restApiHelper.getResponseObject(response);
-    },
+    }
 
     getConfig(noCache) {
       if (!noCache) {
@@ -173,7 +148,7 @@
         url: '/config/server/info',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getRepo(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -183,7 +158,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*',
       });
-    },
+    }
 
     getProjectConfig(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -193,7 +168,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/config',
       });
-    },
+    }
 
     getRepoAccess(repo) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -202,7 +177,7 @@
         url: '/access/?project=' + encodeURIComponent(repo),
         anonymizedUrl: '/access/?project=*',
       });
-    },
+    }
 
     getRepoDashboards(repo, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -212,7 +187,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/dashboards?inherited',
       });
-    },
+    }
 
     saveRepoConfig(repo, config, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -226,7 +201,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/config',
       });
-    },
+    }
 
     runRepoGC(repo, opt_errFn) {
       if (!repo) { return ''; }
@@ -240,7 +215,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/gc',
       });
-    },
+    }
 
     /**
      * @param {?Object} config
@@ -258,7 +233,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*',
       });
-    },
+    }
 
     /**
      * @param {?Object} config
@@ -274,7 +249,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*',
       });
-    },
+    }
 
     getGroupConfig(group, opt_errFn) {
       return this._restApiHelper.fetchJSON({
@@ -282,7 +257,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/detail',
       });
-    },
+    }
 
     /**
      * @param {string} repo
@@ -302,7 +277,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/branches/*',
       });
-    },
+    }
 
     /**
      * @param {string} repo
@@ -322,7 +297,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/tags/*',
       });
-    },
+    }
 
     /**
      * @param {string} name
@@ -343,7 +318,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/branches/*',
       });
-    },
+    }
 
     /**
      * @param {string} name
@@ -364,7 +339,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/tags/*',
       });
-    },
+    }
 
     /**
      * @param {!string} groupName
@@ -378,7 +353,7 @@
       };
       return this._fetchSharedCacheURL(req)
           .then(configs => configs.hasOwnProperty(groupName));
-    },
+    }
 
     getGroupMembers(groupName, opt_errFn) {
       const encodeName = encodeURIComponent(groupName);
@@ -387,14 +362,14 @@
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/members',
       });
-    },
+    }
 
     getIncludedGroup(groupName) {
       return this._restApiHelper.fetchJSON({
         url: `/groups/${encodeURIComponent(groupName)}/groups/`,
         anonymizedUrl: '/groups/*/groups',
       });
-    },
+    }
 
     saveGroupName(groupId, name) {
       const encodeId = encodeURIComponent(groupId);
@@ -404,7 +379,7 @@
         body: {name},
         anonymizedUrl: '/groups/*/name',
       });
-    },
+    }
 
     saveGroupOwner(groupId, ownerId) {
       const encodeId = encodeURIComponent(groupId);
@@ -414,7 +389,7 @@
         body: {owner: ownerId},
         anonymizedUrl: '/groups/*/owner',
       });
-    },
+    }
 
     saveGroupDescription(groupId, description) {
       const encodeId = encodeURIComponent(groupId);
@@ -424,7 +399,7 @@
         body: {description},
         anonymizedUrl: '/groups/*/description',
       });
-    },
+    }
 
     saveGroupOptions(groupId, options) {
       const encodeId = encodeURIComponent(groupId);
@@ -434,7 +409,7 @@
         body: options,
         anonymizedUrl: '/groups/*/options',
       });
-    },
+    }
 
     getGroupAuditLog(group, opt_errFn) {
       return this._fetchSharedCacheURL({
@@ -442,7 +417,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/groups/*/log.audit',
       });
-    },
+    }
 
     saveGroupMembers(groupName, groupMembers) {
       const encodeName = encodeURIComponent(groupName);
@@ -453,7 +428,7 @@
         parseResponse: true,
         anonymizedUrl: '/groups/*/members/*',
       });
-    },
+    }
 
     saveIncludedGroup(groupName, includedGroup, opt_errFn) {
       const encodeName = encodeURIComponent(groupName);
@@ -469,7 +444,7 @@
           return this.getResponseObject(response);
         }
       });
-    },
+    }
 
     deleteGroupMembers(groupName, groupMembers) {
       const encodeName = encodeURIComponent(groupName);
@@ -479,7 +454,7 @@
         url: `/groups/${encodeName}/members/${encodeMember}`,
         anonymizedUrl: '/groups/*/members/*',
       });
-    },
+    }
 
     deleteIncludedGroup(groupName, includedGroup) {
       const encodeName = encodeURIComponent(groupName);
@@ -489,14 +464,14 @@
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
         anonymizedUrl: '/groups/*/groups/*',
       });
-    },
+    }
 
     getVersion() {
       return this._fetchSharedCacheURL({
         url: '/config/server/version',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getDiffPreferences() {
       return this.getLoggedIn().then(loggedIn => {
@@ -527,7 +502,7 @@
           theme: 'DEFAULT',
         });
       });
-    },
+    }
 
     getEditPreferences() {
       return this.getLoggedIn().then(loggedIn => {
@@ -558,7 +533,7 @@
           theme: 'DEFAULT',
         });
       });
-    },
+    }
 
     /**
      * @param {?Object} prefs
@@ -578,7 +553,7 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {?Object} prefs
@@ -594,7 +569,7 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {?Object} prefs
@@ -610,7 +585,7 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getAccount() {
       return this._fetchSharedCacheURL({
@@ -622,7 +597,7 @@
           }
         },
       });
-    },
+    }
 
     getAvatarChangeUrl() {
       return this._fetchSharedCacheURL({
@@ -634,14 +609,14 @@
           }
         },
       });
-    },
+    }
 
     getExternalIds() {
       return this._restApiHelper.fetchJSON({
         url: '/accounts/self/external.ids',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     deleteAccountIdentity(id) {
       return this._restApiHelper.send({
@@ -651,7 +626,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string} userId the ID of the user usch as an email address.
@@ -662,14 +637,14 @@
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
         anonymizedUrl: '/accounts/*/detail',
       });
-    },
+    }
 
     getAccountEmails() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/emails',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string} email
@@ -682,7 +657,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/account/self/emails/*',
       });
-    },
+    }
 
     /**
      * @param {string} email
@@ -695,7 +670,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/accounts/self/email/*',
       });
-    },
+    }
 
     /**
      * @param {string} email
@@ -724,7 +699,7 @@
           this._cache.set('/accounts/self/emails', emails);
         }
       });
-    },
+    }
 
     /**
      * @param {?Object} obj
@@ -738,7 +713,7 @@
         this._cache.set('/accounts/self/detail',
             Object.assign({}, cachedAccount, obj));
       }
-    },
+    }
 
     /**
      * @param {string} name
@@ -755,7 +730,7 @@
       };
       return this._restApiHelper.send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
-    },
+    }
 
     /**
      * @param {string} username
@@ -772,7 +747,7 @@
       };
       return this._restApiHelper.send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
-    },
+    }
 
     /**
      * @param {string} status
@@ -789,28 +764,28 @@
       };
       return this._restApiHelper.send(req)
           .then(newStatus => this._updateCachedAccount({status: newStatus}));
-    },
+    }
 
     getAccountStatus(userId) {
       return this._restApiHelper.fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/status`,
         anonymizedUrl: '/accounts/*/status',
       });
-    },
+    }
 
     getAccountGroups() {
       return this._restApiHelper.fetchJSON({
         url: '/accounts/self/groups',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getAccountAgreements() {
       return this._restApiHelper.fetchJSON({
         url: '/accounts/self/agreements',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     saveAccountAgreement(name) {
       return this._restApiHelper.send({
@@ -819,7 +794,7 @@
         body: name,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string=} opt_params
@@ -828,45 +803,39 @@
       let queryString = '';
       if (opt_params) {
         queryString = '?q=' + opt_params
-            .map(param => { return encodeURIComponent(param); })
+            .map(param => encodeURIComponent(param))
             .join('&q=');
       }
       return this._fetchSharedCacheURL({
         url: '/accounts/self/capabilities' + queryString,
         anonymizedUrl: '/accounts/self/capabilities?q=*',
       });
-    },
+    }
 
     getLoggedIn() {
-      return this.getAccount().then(account => {
-        return account != null;
-      }).catch(() => {
-        return false;
-      });
-    },
+      return this._auth.authCheck();
+    }
 
     getIsAdmin() {
-      return this.getLoggedIn().then(isLoggedIn => {
-        if (isLoggedIn) {
-          return this.getAccountCapabilities();
-        } else {
-          return Promise.resolve();
-        }
-      }).then(capabilities => {
-        return capabilities && capabilities.administrateServer;
-      });
-    },
-
-    checkCredentials() {
-      return this._restApiHelper.checkCredentials();
-    },
+      return this.getLoggedIn()
+          .then(isLoggedIn => {
+            if (isLoggedIn) {
+              return this.getAccountCapabilities();
+            } else {
+              return Promise.resolve();
+            }
+          })
+          .then(
+              capabilities => capabilities && capabilities.administrateServer
+          );
+    }
 
     getDefaultPreferences() {
       return this._fetchSharedCacheURL({
         url: '/config/server/preferences',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
@@ -892,14 +861,14 @@
           size_bar_in_change_table: true,
         });
       });
-    },
+    }
 
     getWatchedProjects() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/watched.projects',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string} projects
@@ -914,7 +883,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string} projects
@@ -928,11 +897,11 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     _isNarrowScreen() {
       return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
-    },
+    }
 
     /**
      * @param {number=} opt_changesPerPage
@@ -988,7 +957,7 @@
         }
         return response;
       });
-    },
+    }
 
     /**
      * Inserts a change into _projectLookup iff it has a valid structure.
@@ -999,7 +968,7 @@
       if (change && change.project && change._number) {
         this.setInProjectLookup(change._number, change.project);
       }
-    },
+    }
 
     /**
      * TODO (beckysiegel) this needs to be rewritten with the optional param
@@ -1013,7 +982,7 @@
     getChangeActionURL(changeNum, opt_patchNum, endpoint) {
       return this._changeBaseURL(changeNum, opt_patchNum)
           .then(url => url + endpoint);
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1032,7 +1001,6 @@
         this.ListChangesOption.MESSAGES,
         this.ListChangesOption.SUBMITTABLE,
         this.ListChangesOption.WEB_LINKS,
-        this.ListChangesOption.SKIP_MERGEABLE,
         this.ListChangesOption.SKIP_DIFFSTAT,
       ];
       return this.getConfig(false).then(config => {
@@ -1044,7 +1012,7 @@
             changeNum, optionsHex, opt_errFn, opt_cancelCondition)
             .then(GrReviewerUpdatesParser.parse);
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1055,12 +1023,11 @@
       const optionsHex = this.listChangesOptionsToHex(
           this.ListChangesOption.ALL_COMMITS,
           this.ListChangesOption.ALL_REVISIONS,
-          this.ListChangesOption.SKIP_MERGEABLE,
           this.ListChangesOption.SKIP_DIFFSTAT
       );
       return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
           opt_cancelCondition);
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1110,7 +1077,7 @@
           });
         });
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1123,7 +1090,7 @@
         patchNum,
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1144,7 +1111,7 @@
         params,
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1162,7 +1129,7 @@
         endpoint,
         anonymizedEndpoint,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1177,7 +1144,7 @@
         patchNum,
         anonymizedEndpoint: '/files?q=*',
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1190,7 +1157,7 @@
           res.files);
       }
       return this.getChangeFiles(changeNum, patchRange);
-    },
+    }
 
     /**
      * The closure compiler doesn't realize this.specialFilePathCompare is
@@ -1203,7 +1170,7 @@
         if (!files) return;
         return Object.keys(files).sort(this.specialFilePathCompare);
       });
-    },
+    }
 
     getChangeRevisionActions(changeNum, patchNum) {
       const req = {
@@ -1221,7 +1188,7 @@
         }
         return revisionActions;
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1231,7 +1198,7 @@
     getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
       return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
           opt_errFn);
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1241,7 +1208,7 @@
     getChangeSuggestedCCs(changeNum, inputVal, opt_errFn) {
       return this._getChangeSuggestedGroup('CC', changeNum, inputVal,
           opt_errFn);
-    },
+    }
 
     _getChangeSuggestedGroup(reviewerState, changeNum, inputVal, opt_errFn) {
       // More suggestions may obscure content underneath in the reply dialog,
@@ -1255,7 +1222,7 @@
         params,
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1266,7 +1233,7 @@
         endpoint: '/in',
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     _computeFilter(filter) {
       if (filter && filter.startsWith('^')) {
@@ -1277,7 +1244,7 @@
         filter = '';
       }
       return filter;
-    },
+    }
 
     /**
      * @param {string} filter
@@ -1289,7 +1256,7 @@
 
       return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
         this._computeFilter(filter);
-    },
+    }
 
     /**
      * @param {string} filter
@@ -1325,15 +1292,19 @@
 
       return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
         `&query=${encodedFilter}`;
-    },
+    }
 
     invalidateGroupsCache() {
       this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
-    },
+    }
 
     invalidateReposCache() {
       this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
-    },
+    }
+
+    invalidateAccountsCache() {
+      this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
+    }
 
     /**
      * @param {string} filter
@@ -1348,7 +1319,7 @@
         url,
         anonymizedUrl: '/groups/?*',
       });
-    },
+    }
 
     /**
      * @param {string} filter
@@ -1365,7 +1336,7 @@
         url,
         anonymizedUrl: '/projects/?*',
       });
-    },
+    }
 
     setRepoHead(repo, ref) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -1376,7 +1347,7 @@
         body: {ref},
         anonymizedUrl: '/projects/*/HEAD',
       });
-    },
+    }
 
     /**
      * @param {string} filter
@@ -1399,7 +1370,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/branches?*',
       });
-    },
+    }
 
     /**
      * @param {string} filter
@@ -1423,7 +1394,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/tags',
       });
-    },
+    }
 
     /**
      * @param {string} filter
@@ -1442,7 +1413,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/plugins/?all',
       });
-    },
+    }
 
     getRepoAccessRights(repoName, opt_errFn) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -1452,7 +1423,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/access',
       });
-    },
+    }
 
     setRepoAccessRights(repoName, repoInfo) {
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
@@ -1463,7 +1434,7 @@
         body: repoInfo,
         anonymizedUrl: '/projects/*/access',
       });
-    },
+    }
 
     setRepoAccessRightsForReview(projectName, projectInfo) {
       return this._restApiHelper.send({
@@ -1473,7 +1444,7 @@
         parseResponse: true,
         anonymizedUrl: '/projects/*/access:review',
       });
-    },
+    }
 
     /**
      * @param {string} inputVal
@@ -1489,7 +1460,7 @@
         params,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string} inputVal
@@ -1509,7 +1480,7 @@
         params,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {string} inputVal
@@ -1528,15 +1499,15 @@
         params,
         anonymizedUrl: '/accounts/?n=*',
       });
-    },
+    }
 
     addChangeReviewer(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
-    },
+    }
 
     removeChangeReviewer(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
-    },
+    }
 
     _sendChangeReviewerRequest(method, changeNum, reviewerID) {
       return this.getChangeActionURL(changeNum, null, '/reviewers')
@@ -1555,7 +1526,7 @@
 
             return this._restApiHelper.send({method, url, body});
           });
-    },
+    }
 
     getRelatedChanges(changeNum, patchNum) {
       return this._getChangeURLAndFetch({
@@ -1564,7 +1535,7 @@
         patchNum,
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     getChangesSubmittedTogether(changeNum) {
       return this._getChangeURLAndFetch({
@@ -1572,7 +1543,7 @@
         endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     getChangeConflicts(changeNum) {
       const options = this.listChangesOptionsToHex(
@@ -1581,14 +1552,14 @@
       );
       const params = {
         O: options,
-        q: 'status:open is:mergeable conflicts:' + changeNum,
+        q: 'status:open conflicts:' + changeNum,
       };
       return this._restApiHelper.fetchJSON({
         url: '/changes/',
         params,
         anonymizedUrl: '/changes/conflicts:*',
       });
-    },
+    }
 
     getChangeCherryPicks(project, changeID, changeNum) {
       const options = this.listChangesOptionsToHex(
@@ -1610,7 +1581,7 @@
         params,
         anonymizedUrl: '/changes/change:*',
       });
-    },
+    }
 
     getChangesWithSameTopic(topic, changeNum) {
       const options = this.listChangesOptionsToHex(
@@ -1633,7 +1604,7 @@
         params,
         anonymizedUrl: '/changes/topic:*',
       });
-    },
+    }
 
     getReviewedFiles(changeNum, patchNum) {
       return this._getChangeURLAndFetch({
@@ -1642,7 +1613,7 @@
         patchNum,
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1660,7 +1631,7 @@
         errFn: opt_errFn,
         anonymizedEndpoint: '/files/*/reviewed',
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1673,15 +1644,13 @@
         this.awaitPendingDiffDrafts(),
         this.getChangeActionURL(changeNum, patchNum, '/review'),
       ];
-      return Promise.all(promises).then(([, url]) => {
-        return this._restApiHelper.send({
-          method: 'POST',
-          url,
-          body: review,
-          errFn: opt_errFn,
-        });
-      });
-    },
+      return Promise.all(promises).then(([, url]) => this._restApiHelper.send({
+        method: 'POST',
+        url,
+        body: review,
+        errFn: opt_errFn,
+      }));
+    }
 
     getChangeEdit(changeNum, opt_download_commands) {
       const params = opt_download_commands ? {'download-commands': true} : null;
@@ -1694,7 +1663,7 @@
           reportEndpointAsIs: true,
         });
       });
-    },
+    }
 
     /**
      * @param {string} project
@@ -1724,7 +1693,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1752,7 +1721,7 @@
           return {content, type, ok: true};
         });
       });
-    },
+    }
 
     /**
      * Gets a file in a specific change and revision.
@@ -1772,7 +1741,7 @@
         headers: {Accept: 'application/json'},
         anonymizedEndpoint: '/files/*/content',
       });
-    },
+    }
 
     /**
      * Gets a file in a change edit.
@@ -1788,7 +1757,7 @@
         headers: {Accept: 'application/json'},
         anonymizedEndpoint: '/edit/*',
       });
-    },
+    }
 
     rebaseChangeEdit(changeNum) {
       return this._getChangeURLAndSend({
@@ -1797,7 +1766,7 @@
         endpoint: '/edit:rebase',
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     deleteChangeEdit(changeNum) {
       return this._getChangeURLAndSend({
@@ -1806,7 +1775,7 @@
         endpoint: '/edit',
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     restoreFileInChangeEdit(changeNum, restore_path) {
       return this._getChangeURLAndSend({
@@ -1816,7 +1785,7 @@
         body: {restore_path},
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     renameFileInChangeEdit(changeNum, old_path, new_path) {
       return this._getChangeURLAndSend({
@@ -1826,7 +1795,7 @@
         body: {old_path, new_path},
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     deleteFileInChangeEdit(changeNum, path) {
       return this._getChangeURLAndSend({
@@ -1835,7 +1804,7 @@
         endpoint: '/edit/' + encodeURIComponent(path),
         anonymizedEndpoint: '/edit/*',
       });
-    },
+    }
 
     saveChangeEdit(changeNum, path, contents) {
       return this._getChangeURLAndSend({
@@ -1846,7 +1815,26 @@
         contentType: 'text/plain',
         anonymizedEndpoint: '/edit/*',
       });
-    },
+    }
+
+    getRobotCommentFixPreview(changeNum, patchNum, fixId) {
+      return this._getChangeURLAndFetch({
+        changeNum,
+        patchNum,
+        endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
+        reportEndpointAsId: true,
+      });
+    }
+
+    applyFixSuggestion(changeNum, patchNum, fixId) {
+      return this._getChangeURLAndSend({
+        method: 'POST',
+        changeNum,
+        patchNum,
+        endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
+        reportEndpointAsId: true,
+      });
+    }
 
     // Deprecated, prefer to use putChangeCommitMessage instead.
     saveChangeCommitMessageEdit(changeNum, message) {
@@ -1857,7 +1845,7 @@
         body: {message},
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     publishChangeEdit(changeNum) {
       return this._getChangeURLAndSend({
@@ -1866,7 +1854,7 @@
         endpoint: '/edit:publish',
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     putChangeCommitMessage(changeNum, message) {
       return this._getChangeURLAndSend({
@@ -1876,7 +1864,7 @@
         body: {message},
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     saveChangeStarred(changeNum, starred) {
       // Some servers may require the project name to be provided
@@ -1891,7 +1879,7 @@
           anonymizedUrl: '/accounts/self/starred.changes/*',
         });
       });
-    },
+    }
 
     saveChangeReviewed(changeNum, reviewed) {
       return this._getChangeURLAndSend({
@@ -1899,7 +1887,7 @@
         method: 'PUT',
         endpoint: reviewed ? '/reviewed' : '/unreviewed',
       });
-    },
+    }
 
     /**
      * Public version of the _restApiHelper.send method preserved for plugins.
@@ -1924,7 +1912,7 @@
         contentType: opt_contentType,
         headers: opt_headers,
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1966,7 +1954,7 @@
       }
 
       return this._getChangeURLAndFetch(req);
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1978,7 +1966,7 @@
     getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
           opt_patchNum, opt_path);
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -1990,7 +1978,7 @@
     getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/robotcomments',
           opt_basePatchNum, opt_patchNum, opt_path);
-    },
+    }
 
     /**
      * If the user is logged in, fetch the user's draft diff comments. If there
@@ -2009,7 +1997,7 @@
         return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
             opt_patchNum, opt_path);
       });
-    },
+    }
 
     _setRange(comments, comment) {
       if (comment.in_reply_to && !comment.range) {
@@ -2021,18 +2009,18 @@
         }
       }
       return comment;
-    },
+    }
 
     _setRanges(comments) {
       comments = comments || [];
-      comments.sort((a, b) => {
-        return util.parseDate(a.updated) - util.parseDate(b.updated);
-      });
+      comments.sort(
+          (a, b) => util.parseDate(a.updated) - util.parseDate(b.updated)
+      );
       for (const comment of comments) {
         this._setRange(comments, comment);
       }
       return comments;
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -2051,14 +2039,12 @@
        * @param {string|number=} opt_patchNum
        * @return {!Promise<!Object>} Diff comments response.
        */
-      const fetchComments = opt_patchNum => {
-        return this._getChangeURLAndFetch({
-          changeNum,
-          endpoint,
-          patchNum: opt_patchNum,
-          reportEndpointAsIs: true,
-        });
-      };
+      const fetchComments = opt_patchNum => this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        patchNum: opt_patchNum,
+        reportEndpointAsIs: true,
+      });
 
       if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
         return fetchComments();
@@ -2099,13 +2085,11 @@
         promises.push(fetchPromise);
       }
 
-      return Promise.all(promises).then(() => {
-        return Promise.resolve({
-          baseComments,
-          comments,
-        });
-      });
-    },
+      return Promise.all(promises).then(() => Promise.resolve({
+        baseComments,
+        comments,
+      }));
+    }
 
     /**
      * @param {number|string} changeNum
@@ -2115,15 +2099,15 @@
     _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
       return this._changeBaseURL(changeNum, opt_patchNum)
           .then(url => url + endpoint);
-    },
+    }
 
     saveDiffDraft(changeNum, patchNum, draft) {
       return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
-    },
+    }
 
     deleteDiffDraft(changeNum, patchNum, draft) {
       return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
-    },
+    }
 
     /**
      * @returns {boolean} Whether there are pending diff draft sends.
@@ -2131,7 +2115,7 @@
     hasPendingDiffDrafts() {
       const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
       return promises && promises.length;
-    },
+    }
 
     /**
      * @returns {!Promise<undefined>} A promise that resolves when all pending
@@ -2142,7 +2126,7 @@
           .then(() => {
             this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
           });
-    },
+    }
 
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
       const isCreate = !draft.id && method === 'PUT';
@@ -2178,7 +2162,7 @@
       }
 
       return promise;
-    },
+    }
 
     getCommitInfo(project, commit) {
       return this._restApiHelper.fetchJSON({
@@ -2186,7 +2170,7 @@
             '/commits/' + encodeURIComponent(commit),
         anonymizedUrl: '/projects/*/comments/*',
       });
-    },
+    }
 
     _fetchB64File(url) {
       return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
@@ -2200,7 +2184,7 @@
                   return {body: text, type};
                 });
           });
-    },
+    }
 
     /**
      * @param {string} changeId
@@ -2215,7 +2199,7 @@
         url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
         return this._fetchB64File(url);
       });
-    },
+    }
 
     getImagesForDiff(changeNum, diff, patchRange) {
       let promiseA;
@@ -2257,7 +2241,7 @@
 
         return {baseImage, revisionImage};
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -2278,7 +2262,7 @@
         }
         return url;
       });
-    },
+    }
 
     /**
      * @suppress {checkTypes}
@@ -2294,7 +2278,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @suppress {checkTypes}
@@ -2310,7 +2294,7 @@
         parseResponse: true,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     deleteAccountHttpPassword() {
       return this._restApiHelper.send({
@@ -2318,7 +2302,7 @@
         url: '/accounts/self/password.http',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @suppress {checkTypes}
@@ -2333,14 +2317,14 @@
         parseResponse: true,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getAccountSSHKeys() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/sshkeys',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     addAccountSSHKey(key) {
       const req = {
@@ -2361,7 +2345,7 @@
             if (!obj.valid) { return Promise.reject(new Error('error')); }
             return obj;
           });
-    },
+    }
 
     deleteAccountSSHKey(id) {
       return this._restApiHelper.send({
@@ -2369,14 +2353,14 @@
         url: '/accounts/self/sshkeys/' + id,
         anonymizedUrl: '/accounts/self/sshkeys/*',
       });
-    },
+    }
 
     getAccountGPGKeys() {
       return this._restApiHelper.fetchJSON({
         url: '/accounts/self/gpgkeys',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     addAccountGPGKey(key) {
       const req = {
@@ -2396,7 +2380,7 @@
             if (!obj) { return Promise.reject(new Error('error')); }
             return obj;
           });
-    },
+    }
 
     deleteAccountGPGKey(id) {
       return this._restApiHelper.send({
@@ -2404,7 +2388,7 @@
         url: '/accounts/self/gpgkeys/' + id,
         anonymizedUrl: '/accounts/self/gpgkeys/*',
       });
-    },
+    }
 
     deleteVote(changeNum, account, label) {
       return this._getChangeURLAndSend({
@@ -2413,7 +2397,7 @@
         endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
         anonymizedEndpoint: '/reviewers/*/votes/*',
       });
-    },
+    }
 
     setDescription(changeNum, patchNum, desc) {
       return this._getChangeURLAndSend({
@@ -2423,7 +2407,7 @@
         body: {description: desc},
         reportUrlAsIs: true,
       });
-    },
+    }
 
     confirmEmail(token) {
       const req = {
@@ -2438,7 +2422,7 @@
         }
         return null;
       });
-    },
+    }
 
     getCapabilities(opt_errFn) {
       return this._restApiHelper.fetchJSON({
@@ -2446,7 +2430,7 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     getTopMenus(opt_errFn) {
       return this._fetchSharedCacheURL({
@@ -2454,7 +2438,7 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     setAssignee(changeNum, assignee) {
       return this._getChangeURLAndSend({
@@ -2464,7 +2448,7 @@
         body: {assignee},
         reportUrlAsIs: true,
       });
-    },
+    }
 
     deleteAssignee(changeNum) {
       return this._getChangeURLAndSend({
@@ -2473,14 +2457,12 @@
         endpoint: '/assignee',
         reportUrlAsIs: true,
       });
-    },
+    }
 
     probePath(path) {
       return fetch(new Request(path, {method: 'HEAD'}))
-          .then(response => {
-            return response.ok;
-          });
-    },
+          .then(response => response.ok);
+    }
 
     /**
      * @param {number|string} changeNum
@@ -2503,7 +2485,7 @@
           return 'Change marked as Work In Progress.';
         }
       });
-    },
+    }
 
     /**
      * @param {number|string} changeNum
@@ -2519,7 +2501,7 @@
         errFn: opt_errFn,
         reportUrlAsIs: true,
       });
-    },
+    }
 
     /**
      * @suppress {checkTypes}
@@ -2536,7 +2518,7 @@
         parseResponse: true,
         anonymizedEndpoint: '/comments/*/delete',
       });
-    },
+    }
 
     /**
      * Given a changeNum, gets the change.
@@ -2555,7 +2537,7 @@
         if (!res || !res.length) { return null; }
         return res[0];
       });
-    },
+    }
 
     /**
      * @param {string|number} changeNum
@@ -2568,7 +2550,7 @@
             'One of them must be invalid.');
       }
       this._projectLookup[changeNum] = project;
-    },
+    }
 
     /**
      * Checks in _projectLookup for the changeNum. If it exists, returns the
@@ -2592,7 +2574,7 @@
         this.setInProjectLookup(changeNum, change.project);
         return change.project;
       });
-    },
+    }
 
     /**
      * Alias for _changeBaseURL.then(send).
@@ -2607,20 +2589,19 @@
       const anonymizedEndpoint = req.reportEndpointAsIs ?
         req.endpoint : req.anonymizedEndpoint;
 
-      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-        return this._restApiHelper.send({
-          method: req.method,
-          url: url + req.endpoint,
-          body: req.body,
-          errFn: req.errFn,
-          contentType: req.contentType,
-          headers: req.headers,
-          parseResponse: req.parseResponse,
-          anonymizedUrl: anonymizedEndpoint ?
-            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-        });
-      });
-    },
+      return this._changeBaseURL(req.changeNum, req.patchNum)
+          .then(url => this._restApiHelper.send({
+            method: req.method,
+            url: url + req.endpoint,
+            body: req.body,
+            errFn: req.errFn,
+            contentType: req.contentType,
+            headers: req.headers,
+            parseResponse: req.parseResponse,
+            anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
+          }));
+    }
 
     /**
      * Alias for _changeBaseURL.then(_fetchJSON).
@@ -2633,17 +2614,16 @@
         req.endpoint : req.anonymizedEndpoint;
       const anonymizedBaseUrl = req.patchNum ?
         ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
-      return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-        return this._restApiHelper.fetchJSON({
-          url: url + req.endpoint,
-          errFn: req.errFn,
-          params: req.params,
-          fetchOptions: req.fetchOptions,
-          anonymizedUrl: anonymizedEndpoint ?
-            (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-        });
-      });
-    },
+      return this._changeBaseURL(req.changeNum, req.patchNum)
+          .then(url => this._restApiHelper.fetchJSON({
+            url: url + req.endpoint,
+            errFn: req.errFn,
+            params: req.params,
+            fetchOptions: req.fetchOptions,
+            anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
+          }));
+    }
 
     /**
      * Execute a change action or revision action on a change.
@@ -2666,7 +2646,7 @@
         body: opt_payload,
         errFn: opt_errFn,
       });
-    },
+    }
 
     /**
      * Get blame information for the given diff.
@@ -2687,7 +2667,7 @@
         params: opt_base ? {base: 't'} : undefined,
         anonymizedEndpoint: '/files/*/blame',
       });
-    },
+    }
 
     /**
      * Modify the given create draft request promise so that it fails and throws
@@ -2717,7 +2697,7 @@
         }
         return result;
       });
-    },
+    }
 
     /**
      * Fetch a project dashboard definition.
@@ -2737,7 +2717,7 @@
         errFn: opt_errFn,
         anonymizedUrl: '/projects/*/dashboards/*',
       });
-    },
+    }
 
     /**
      * @param {string} filter
@@ -2753,7 +2733,7 @@
         url: `/Documentation/?q=${encodedFilter}`,
         anonymizedUrl: '/Documentation/?*',
       });
-    },
+    }
 
     getMergeable(changeNum) {
       return this._getChangeURLAndFetch({
@@ -2762,7 +2742,7 @@
         parseResponse: true,
         reportEndpointAsIs: true,
       });
-    },
+    }
 
     deleteDraftComments(query) {
       return this._restApiHelper.send({
@@ -2770,6 +2750,8 @@
         url: '/accounts/self/drafts:delete',
         body: {query},
       });
-    },
-  });
-})();
+    }
+  }
+
+  customElements.define(GrRestApiInterface.is, GrRestApiInterface);
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 1781ce7..ca4b246 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-interface</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -48,8 +48,6 @@
       window.CANONICAL_PATH = `test${ctr}`;
 
       sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element._projectLookup = {};
       const testJSON = ')]}\'\n{"hello": "bonjour"}';
       sandbox.stub(window, 'fetch').returns(Promise.resolve({
         ok: true,
@@ -57,6 +55,10 @@
           return Promise.resolve(testJSON);
         },
       }));
+      // fake auth
+      sandbox.stub(Gerrit.Auth, 'authCheck').returns(Promise.resolve(true));
+      element = fixture('basic');
+      element._projectLookup = {};
     });
 
     teardown(() => {
@@ -64,22 +66,20 @@
     });
 
     test('parent diff comments are properly grouped', done => {
-      sandbox.stub(element._restApiHelper, 'fetchJSON', () => {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              updated: '2017-02-03 22:32:28.000000000',
-              message: 'this isn’t quite right',
-            },
-            {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        });
-      });
+      sandbox.stub(element._restApiHelper, 'fetchJSON', () => Promise.resolve({
+        '/COMMIT_MSG': [],
+        'sieve.go': [
+          {
+            updated: '2017-02-03 22:32:28.000000000',
+            message: 'this isn’t quite right',
+          },
+          {
+            side: 'PARENT',
+            message: 'how did this work in the first place?',
+            updated: '2017-02-03 22:33:28.000000000',
+          },
+        ],
+      }));
       element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
           obj => {
             assert.equal(obj.baseComments.length, 1);
@@ -351,7 +351,6 @@
       });
     });
 
-
     test('server error', done => {
       const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
       window.fetch.returns(Promise.resolve({ok: false}));
@@ -366,117 +365,6 @@
       });
     });
 
-    test('auth failure', done => {
-      const fakeAuthResponse = {
-        ok: false,
-        status: 403,
-      };
-      window.fetch.onFirstCall().returns(
-          Promise.reject(new Error('Failed to fetch')));
-      window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
-      // Emulate logged in.
-      element._restApiHelper._cache.set('/accounts/self/detail', {});
-      const serverErrorStub = sandbox.stub();
-      element.addEventListener('server-error', serverErrorStub);
-      const authErrorStub = sandbox.stub();
-      element.addEventListener('auth-error', authErrorStub);
-      element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
-        flush(() => {
-          assert.isTrue(authErrorStub.called);
-          assert.isFalse(serverErrorStub.called);
-          assert.isFalse(element._cache.has('/accounts/self/detail'));
-          done();
-        });
-      });
-    });
-
-    test('auth failure - test all failed to fetch', done => {
-      window.fetch.returns(
-          Promise.reject(new Error('Failed to fetch')));
-      // Emulate logged in.
-      element._cache.set('/accounts/self/detail', {});
-      const serverErrorStub = sandbox.stub();
-      element.addEventListener('server-error', serverErrorStub);
-      const authErrorStub = sandbox.stub();
-      element.addEventListener('auth-error', authErrorStub);
-      element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
-        flush(() => {
-          assert.isTrue(authErrorStub.called);
-          assert.isFalse(serverErrorStub.called);
-          assert.isFalse(element._cache.has('/accounts/self/detail'));
-          done();
-        });
-      });
-    });
-
-    test('getLoggedIn returns false when network/auth failure', done => {
-      window.fetch.returns(
-          Promise.reject(new Error('Failed to fetch')));
-      element.getLoggedIn().then(isLoggedIn => {
-        assert.isFalse(isLoggedIn);
-        done();
-      });
-    });
-
-    test('checkCredentials', done => {
-      const responses = [
-        {
-          ok: false,
-          status: 403,
-          text() { return Promise.resolve(); },
-        },
-        {
-          ok: true,
-          status: 200,
-          text() { return Promise.resolve(')]}\'{}'); },
-        },
-      ];
-      window.fetch.restore();
-      sandbox.stub(window, 'fetch', url => {
-        if (url === window.CANONICAL_PATH + '/accounts/self/detail') {
-          return Promise.resolve(responses.shift());
-        }
-      });
-
-      element.getLoggedIn().then(account => {
-        assert.isNotOk(account);
-        element.checkCredentials().then(account => {
-          assert.isOk(account);
-          done();
-        });
-      });
-    });
-
-    test('checkCredentials promise rejection', () => {
-      window.fetch.restore();
-      element._cache.set('/accounts/self/detail', true);
-      const checkCredentialsSpy =
-          sandbox.spy(element._restApiHelper, 'checkCredentials');
-      sandbox.stub(window, 'fetch', url => {
-        return Promise.reject(new Error('Failed to fetch'));
-      });
-      return element.getConfig(true)
-          .catch(err => undefined)
-          .then(() => {
-            // When the top-level fetch call throws an error, it invokes
-            // checkCredentials, which in turn makes another fetch call.
-            // The second fetch call also fails, which leads to a second
-            // invocation of checkCredentials, which should immediately
-            // return instead of making further fetch calls.
-            assert.isTrue(checkCredentialsSpy .calledTwice);
-            assert.isTrue(window.fetch.calledTwice);
-          });
-    });
-
-    test('checkCredentials accepts only json', () => {
-      const authFetchStub = sandbox.stub(element._auth, 'fetch')
-          .returns(Promise.resolve());
-      element.checkCredentials();
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          'application/json');
-    });
-
     test('legacy n,z key in change url is replaced', () => {
       const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
           .returns(Promise.resolve([]));
@@ -540,15 +428,12 @@
     });
 
     const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
-      sandbox.stub(element, 'getLoggedIn', () => {
-        return Promise.resolve(loggedIn);
-      });
-      sandbox.stub(element, '_isNarrowScreen', () => {
-        return smallScreen;
-      });
-      sandbox.stub(element._restApiHelper, 'fetchCacheURL', () => {
-        return Promise.resolve(testJSON);
-      });
+      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(loggedIn));
+      sandbox.stub(element, '_isNarrowScreen', () => smallScreen);
+      sandbox.stub(
+          element._restApiHelper,
+          'fetchCacheURL',
+          () => Promise.resolve(testJSON));
     };
 
     test('getPreferences returns correctly on small screens logged in',
@@ -647,9 +532,7 @@
     });
 
     test('getEditPreferences returns correct defaults', done => {
-      sandbox.stub(element, 'getLoggedIn', () => {
-        return Promise.resolve(false);
-      });
+      sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
 
       element.getEditPreferences().then(obj => {
         assert.equal(obj.auto_close_brackets, false);
@@ -760,15 +643,17 @@
               ['Innocuous', 'hello'],
             ]},
           };
-          element._failForCreate200(Promise.resolve(result)).then(() => {
-            assert.isTrue(false, 'Promise should not resolve');
-          }).catch(e => {
-            assert.isOk(e);
-            assert.include(e.message, 'Saving draft resulted in HTTP 200');
-            assert.include(e.message, 'hello');
-            assert.notInclude(e.message, 'secret');
-            done();
-          });
+          element._failForCreate200(Promise.resolve(result))
+              .then(() => {
+                assert.isTrue(false, 'Promise should not resolve');
+              })
+              .catch(e => {
+                assert.isOk(e);
+                assert.include(e.message, 'Saving draft resulted in HTTP 200');
+                assert.include(e.message, 'hello');
+                assert.notInclude(e.message, 'secret');
+                done();
+              });
         });
 
         test('_failForCreate200 does not fail on 201', done => {
@@ -777,11 +662,13 @@
             status: 201,
             headers: {entries: () => []},
           };
-          element._failForCreate200(Promise.resolve(result)).then(() => {
-            done();
-          }).catch(e => {
-            assert.isTrue(false, 'Promise should not fail');
-          });
+          element._failForCreate200(Promise.resolve(result))
+              .then(() => {
+                done();
+              })
+              .catch(e => {
+                assert.isTrue(false, 'Promise should not fail');
+              });
         });
       });
     });
@@ -923,6 +810,18 @@
       assert.isFalse(element._cache.has(url));
     });
 
+    test('invalidateAccountsCache', () => {
+      const url = '/accounts/self/detail';
+
+      element._cache.set(url, {});
+
+      element.invalidateAccountsCache();
+
+      assert.isUndefined(element._sharedFetchPromises[url]);
+
+      assert.isFalse(element._cache.has(url));
+    });
+
     suite('getRepos', () => {
       const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
       let fetchCacheURLStub;
@@ -1076,12 +975,12 @@
           toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
               options => 'deadbeef');
           sandbox.stub(element, '_getChangeDetail',
-              async (changeNum, options) => ({changeNum, options}));
+              async (changeNum, options) => { return {changeNum, options}; });
         });
 
         test('signed pushes disabled', async () => {
           const {PUSH_CERTIFICATES} = element.ListChangesOption;
-          sandbox.stub(element, 'getConfig', async () => ({}));
+          sandbox.stub(element, 'getConfig', async () => { return {}; });
           const {changeNum, options} = await element.getChangeDetail(123);
           assert.strictEqual(123, changeNum);
           assert.strictEqual('deadbeef', options);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
index 3908a00..91fef29 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -18,7 +18,6 @@
   'use strict';
 
   const JSON_PREFIX = ')]}\'';
-  const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
 
   /**
    * Wrapper around Map for caching server responses. Site-based so that
@@ -107,15 +106,13 @@
      * @param {SiteBasedCache} cache
      * @param {object} auth
      * @param {FetchPromisesCache} fetchPromisesCache
-     * @param {object} credentialCheck
      * @param {object} restApiInterface
      */
-    constructor(cache, auth, fetchPromisesCache, credentialCheck,
+    constructor(cache, auth, fetchPromisesCache,
         restApiInterface) {
       this._cache = cache;// TODO: make it public
       this._auth = auth;
       this._fetchPromisesCache = fetchPromisesCache;
-      this._credentialCheck = credentialCheck;
       this._restApiInterface = restApiInterface;
     }
 
@@ -183,25 +180,22 @@
         fetchOptions: req.fetchOptions,
         anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
       };
-      return this.fetch(fetchReq).then(res => {
-        if (req.cancelCondition && req.cancelCondition()) {
-          res.body.cancel();
-          return;
-        }
-        return res;
-      }).catch(err => {
-        const isLoggedIn = !!this._cache.get('/accounts/self/detail');
-        if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
-          this.checkCredentials();
-        } else {
-          if (req.errFn) {
-            req.errFn.call(undefined, null, err);
-          } else {
-            this.fire('network-error', {error: err});
-          }
-        }
-        throw err;
-      });
+      return this.fetch(fetchReq)
+          .then(res => {
+            if (req.cancelCondition && req.cancelCondition()) {
+              res.body.cancel();
+              return;
+            }
+            return res;
+          })
+          .catch(err => {
+            if (req.errFn) {
+              req.errFn.call(undefined, null, err);
+            } else {
+              this.fire('network-error', {error: err});
+            }
+            throw err;
+          });
     }
 
     /**
@@ -317,16 +311,18 @@
         return Promise.resolve(this._cache.get(req.url));
       }
       this._fetchPromisesCache.set(req.url,
-          this.fetchJSON(req).then(response => {
-            if (response !== undefined) {
-              this._cache.set(req.url, response);
-            }
-            this._fetchPromisesCache.set(req.url, undefined);
-            return response;
-          }).catch(err => {
-            this._fetchPromisesCache.set(req.url, undefined);
-            throw err;
-          })
+          this.fetchJSON(req)
+              .then(response => {
+                if (response !== undefined) {
+                  this._cache.set(req.url, response);
+                }
+                this._fetchPromisesCache.set(req.url, undefined);
+                return response;
+              })
+              .catch(err => {
+                this._fetchPromisesCache.set(req.url, undefined);
+                throw err;
+              })
       );
       return this._fetchPromisesCache.get(req.url);
     }
@@ -368,14 +364,15 @@
           this.fire('server-error', {request: fetchReq, response});
         }
         return response;
-      }).catch(err => {
-        this.fire('network-error', {error: err});
-        if (req.errFn) {
-          return req.errFn.call(undefined, null, err);
-        } else {
-          throw err;
-        }
-      });
+      })
+          .catch(err => {
+            this.fire('network-error', {error: err});
+            if (req.errFn) {
+              return req.errFn.call(undefined, null, err);
+            } else {
+              throw err;
+            }
+          });
 
       if (req.parseResponse) {
         return xhr.then(res => this.getResponseObject(res));
@@ -384,37 +381,6 @@
       return xhr;
     }
 
-    checkCredentials() {
-      if (this._credentialCheck.checking) {
-        return;
-      }
-      this._credentialCheck.checking = true;
-      let req = {url: '/accounts/self/detail', reportUrlAsIs: true};
-      req = this.addAcceptJsonHeader(req);
-      // Skip the REST response cache.
-      return this.fetchRawJSON(req).then(res => {
-        if (!res) { return; }
-        if (res.status === 403) {
-          this.fire('auth-error');
-          this._cache.delete('/accounts/self/detail');
-        } else if (res.ok) {
-          return this.getResponseObject(res);
-        }
-      }).then(res => {
-        this._credentialCheck.checking = false;
-        if (res) {
-          this._cache.set('/accounts/self/detail', res);
-        }
-        return res;
-      }).catch(err => {
-        this._credentialCheck.checking = false;
-        if (err && err.message === FAILED_TO_FETCH_ERROR) {
-          this.fire('auth-error');
-          this._cache.delete('/accounts/self/detail');
-        }
-      });
-    }
-
     /**
      * @param {string} prefix
      */
@@ -428,4 +394,3 @@
   window.FetchPromisesCache = FetchPromisesCache;
   window.GrRestApiHelper = GrRestApiHelper;
 })(window);
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
index 4eaf1bc..6458104 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-rest-api-helper</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -41,7 +41,6 @@
       sandbox = sinon.sandbox.create();
       cache = new SiteBasedCache();
       fetchPromisesCache = new FetchPromisesCache();
-      const credentialCheck = {checking: false};
 
       window.CANONICAL_PATH = 'testhelper';
 
@@ -59,7 +58,7 @@
       }));
 
       helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
-          credentialCheck, mockRestApiInterface);
+          mockRestApiInterface);
     });
 
     teardown(() => {
@@ -98,9 +97,7 @@
 
     test('cached results', done => {
       let n = 0;
-      sandbox.stub(helper, 'fetchJSON', () => {
-        return Promise.resolve(++n);
-      });
+      sandbox.stub(helper, 'fetchJSON', () => Promise.resolve(++n));
       const promises = [];
       promises.push(helper.fetchCacheURL('/foo'));
       promises.push(helper.fetchCacheURL('/foo'));
@@ -165,7 +162,7 @@
           cancel() { cancelCalled = true; },
         },
       }));
-      const cancelCondition = () => { return true; };
+      const cancelCondition = () => true;
       helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
           obj => {
             assert.isUndefined(obj);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
index d883ef6..1c62ca3 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -20,6 +20,7 @@
   // Prevent redefinition.
   if (window.GrReviewerUpdatesParser) { return; }
 
+  /** @constructor */
   function GrReviewerUpdatesParser(change) {
     this.result = Object.assign({}, change);
     this._lastState = {};
@@ -53,9 +54,10 @@
    * are used.
    */
   GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
-    this.result.messages = this.result.messages.filter(message => {
-      return message.tag !== 'autogenerated:gerrit:deleteReviewer';
-    });
+    this.result.messages = this.result.messages
+        .filter(
+            message => message.tag !== 'autogenerated:gerrit:deleteReviewer'
+        );
   };
 
   /**
@@ -211,12 +213,14 @@
         util.parseDate(messages[index + 1].date).getTime();
       for (const update of updates) {
         const date = util.parseDate(update.date).getTime();
-        if (date >= messageDate
-            && (!nextMessageDate || date < nextMessageDate)) {
+        if (date >= messageDate &&
+            (!nextMessageDate || date < nextMessageDate)) {
           const timestamp = util.parseDate(update.date).getTime() -
               GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
           update.date = new Date(timestamp)
-              .toISOString().replace('T', ' ').replace('Z', '000000');
+              .toISOString()
+              .replace('T', ' ')
+              .replace('Z', '000000');
         }
         if (nextMessageDate && date > nextMessageDate) {
           break;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
index fdf79af..818bfa9 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-updates-parser</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -253,10 +253,10 @@
 
     test('_advanceUpdates', () => {
       const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
-      const tplus = delta => {
-        return new Date(T0 + delta)
-            .toISOString().replace('T', ' ').replace('Z', '000000');
-      };
+      const tplus = delta => new Date(T0 + delta)
+          .toISOString()
+          .replace('T', ' ')
+          .replace('Z', '000000');
       const change = {
         reviewer_updates: [{
           date: tplus(0),
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index 05267ba..ea838a0 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -17,29 +17,30 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-select',
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @extends Polymer.Element
+   */
+  class GrSelect extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-select'; }
 
-    properties: {
-      bindValue: {
-        type: String,
-        notify: true,
-        observer: '_updateValue',
-      },
-    },
-
-    behaviors: [
-      Gerrit.FireBehavior,
-    ],
-
-    listeners: {
-      'change': '_valueChanged',
-      'dom-change': '_updateValue',
-    },
+    static get properties() {
+      return {
+        bindValue: {
+          type: String,
+          notify: true,
+          observer: '_updateValue',
+        },
+      };
+    }
 
     get nativeSelect() {
       return this.$$('select');
-    },
+    }
 
     _updateValue() {
       // It's possible to have a value of 0.
@@ -53,21 +54,34 @@
           this.nativeSelect.value = this.bindValue;
         }, 1);
       }
-    },
+    }
 
     _valueChanged() {
       this.bindValue = this.nativeSelect.value;
-    },
+    }
 
     focus() {
       this.nativeSelect.focus();
-    },
+    }
 
+    /** @override */
+    created() {
+      super.created();
+      this.addEventListener('change',
+          () => this._valueChanged());
+      this.addEventListener('dom-change',
+          () => this._updateValue());
+    }
+
+    /** @override */
     ready() {
+      super.ready();
       // If not set via the property, set bind-value to the element value.
       if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
         this.bindValue = this.nativeSelect.value;
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrSelect.is, GrSelect);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index b3abe5f..cae61056 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-select</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
index 2c546cc..4b31086 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -17,16 +17,23 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-shell-command',
+  /** @extends Polymer.Element */
+  class GrShellCommand extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-shell-command'; }
 
-    properties: {
-      command: String,
-      label: String,
-    },
+    static get properties() {
+      return {
+        command: String,
+        label: String,
+      };
+    }
 
     focusOnCopy() {
       this.$$('gr-copy-clipboard').focusOnCopy();
-    },
-  });
+    }
+  }
+
+  customElements.define(GrShellCommand.is, GrShellCommand);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
index 3f2f8ba..a4bdf58 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-shell-command</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index f6ade6e..53311b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -28,52 +28,57 @@
     'editablecontent:',
   ];
 
-  Polymer({
-    is: 'gr-storage',
+  /** @extends Polymer.Element */
+  class GrStorage extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-storage'; }
 
-    properties: {
-      _lastCleanup: Number,
-      /** @type {?Storage} */
-      _storage: {
-        type: Object,
-        value() {
-          return window.localStorage;
+    static get properties() {
+      return {
+        _lastCleanup: Number,
+        /** @type {?Storage} */
+        _storage: {
+          type: Object,
+          value() {
+            return window.localStorage;
+          },
         },
-      },
-      _exceededQuota: {
-        type: Boolean,
-        value: false,
-      },
-    },
+        _exceededQuota: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
 
     getDraftComment(location) {
       this._cleanupItems();
       return this._getObject(this._getDraftKey(location));
-    },
+    }
 
     setDraftComment(location, message) {
       const key = this._getDraftKey(location);
       this._setObject(key, {message, updated: Date.now()});
-    },
+    }
 
     eraseDraftComment(location) {
       const key = this._getDraftKey(location);
       this._storage.removeItem(key);
-    },
+    }
 
     getEditableContentItem(key) {
       this._cleanupItems();
       return this._getObject(this._getEditableContentKey(key));
-    },
+    }
 
     setEditableContentItem(key, message) {
       this._setObject(this._getEditableContentKey(key),
           {message, updated: Date.now()});
-    },
+    }
 
     eraseEditableContentItem(key) {
       this._storage.removeItem(this._getEditableContentKey(key));
-    },
+    }
 
     _getDraftKey(location) {
       const range = location.range ?
@@ -86,11 +91,11 @@
         key = key + ':' + range;
       }
       return key;
-    },
+    }
 
     _getEditableContentKey(key) {
       return `editablecontent:${key}`;
-    },
+    }
 
     _cleanupItems() {
       // Throttle cleanup to the throttle interval.
@@ -113,13 +118,13 @@
           }
         }
       }
-    },
+    }
 
     _getObject(key) {
       const serial = this._storage.getItem(key);
       if (!serial) { return null; }
       return JSON.parse(serial);
-    },
+    }
 
     _setObject(key, obj) {
       if (this._exceededQuota) { return; }
@@ -136,6 +141,8 @@
           throw exc;
         }
       }
-    },
-  });
+    }
+  }
+
+  customElements.define(GrStorage.is, GrStorage);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 0482584..5cd4d3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
index 6803eb9..42a4f3b 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -59,11 +59,7 @@
         visibility: visible;
         white-space: normal;
       }
-      /*This is needed to not add a scroll bar on the side of gr-textarea
-      since there is 2px of padding in iron-autogrow-textarea for the
-      native textarea*/
       iron-autogrow-textarea {
-        padding: 2px;
         position: relative;
 
         /** This is needed for firefox */
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index 4c4b038..a604bcf 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -52,73 +52,83 @@
     {value: '😜', match: 'winking tongue ;)'},
   ];
 
-  Polymer({
-    is: 'gr-textarea',
-
+  /**
+   * @appliesMixin Gerrit.FireMixin
+   * @appliesMixin Gerrit.KeyboardShortcutMixin
+   * @extends Polymer.Element
+   */
+  class GrTextarea extends Polymer.mixinBehaviors( [
+    Gerrit.FireBehavior,
+    Gerrit.KeyboardShortcutBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-textarea'; }
     /**
      * @event bind-value-changed
      */
 
-    properties: {
-      autocomplete: Boolean,
-      disabled: Boolean,
-      rows: Number,
-      maxRows: Number,
-      placeholder: String,
-      text: {
-        type: String,
-        notify: true,
-        observer: '_handleTextChanged',
-      },
-      hideBorder: {
-        type: Boolean,
-        value: false,
-      },
-      /** Text input should be rendered in monspace font.  */
-      monospace: {
-        type: Boolean,
-        value: false,
-      },
-      /** Text input should be rendered in code font, which is smaller than the
+    static get properties() {
+      return {
+        autocomplete: Boolean,
+        disabled: Boolean,
+        rows: Number,
+        maxRows: Number,
+        placeholder: String,
+        text: {
+          type: String,
+          notify: true,
+          observer: '_handleTextChanged',
+        },
+        hideBorder: {
+          type: Boolean,
+          value: false,
+        },
+        /** Text input should be rendered in monspace font.  */
+        monospace: {
+          type: Boolean,
+          value: false,
+        },
+        /** Text input should be rendered in code font, which is smaller than the
           standard monospace font. */
-      code: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type(?number) */
-      _colonIndex: Number,
-      _currentSearchString: {
-        type: String,
-        observer: '_determineSuggestions',
-      },
-      _hideAutocomplete: {
-        type: Boolean,
-        value: true,
-      },
-      _index: Number,
-      _suggestions: Array,
-      // Offset makes dropdown appear below text.
-      _verticalOffset: {
-        type: Number,
-        value: 20,
-        readOnly: true,
-      },
-    },
+        code: {
+          type: Boolean,
+          value: false,
+        },
+        /** @type {?number} */
+        _colonIndex: Number,
+        _currentSearchString: {
+          type: String,
+          observer: '_determineSuggestions',
+        },
+        _hideAutocomplete: {
+          type: Boolean,
+          value: true,
+        },
+        _index: Number,
+        _suggestions: Array,
+        // Offset makes dropdown appear below text.
+        _verticalOffset: {
+          type: Number,
+          value: 20,
+          readOnly: true,
+        },
+      };
+    }
 
-    behaviors: [
-      Gerrit.FireBehavior,
-      Gerrit.KeyboardShortcutBehavior,
-    ],
+    get keyBindings() {
+      return {
+        esc: '_handleEscKey',
+        tab: '_handleEnterByKey',
+        enter: '_handleEnterByKey',
+        up: '_handleUpKey',
+        down: '_handleDownKey',
+      };
+    }
 
-    keyBindings: {
-      esc: '_handleEscKey',
-      tab: '_handleEnterByKey',
-      enter: '_handleEnterByKey',
-      up: '_handleUpKey',
-      down: '_handleDownKey',
-    },
-
+    /** @override */
     ready() {
+      super.ready();
       if (this.monospace) {
         this.classList.add('monospace');
       }
@@ -128,15 +138,15 @@
       if (this.hideBorder) {
         this.$.textarea.classList.add('noBorder');
       }
-    },
+    }
 
     closeDropdown() {
       return this.$.emojiSuggestions.close();
-    },
+    }
 
     getNativeTextarea() {
       return this.$.textarea.textarea;
-    },
+    }
 
     putCursorAtEnd() {
       const textarea = this.getNativeTextarea();
@@ -146,14 +156,14 @@
       this.async(() => {
         textarea.focus();
       });
-    },
+    }
 
     _handleEscKey(e) {
       if (this._hideAutocomplete) { return; }
       e.preventDefault();
       e.stopPropagation();
       this._resetEmojiDropdown();
-    },
+    }
 
     _handleUpKey(e) {
       if (this._hideAutocomplete) { return; }
@@ -162,7 +172,7 @@
       this.$.emojiSuggestions.cursorUp();
       this.$.textarea.textarea.focus();
       this.disableEnterKeyForSelectingEmoji = false;
-    },
+    }
 
     _handleDownKey(e) {
       if (this._hideAutocomplete) { return; }
@@ -171,7 +181,7 @@
       this.$.emojiSuggestions.cursorDown();
       this.$.textarea.textarea.focus();
       this.disableEnterKeyForSelectingEmoji = false;
-    },
+    }
 
     _handleEnterByKey(e) {
       if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
@@ -180,11 +190,11 @@
       e.preventDefault();
       e.stopPropagation();
       this._setEmoji(this.$.emojiSuggestions.getCurrentText());
-    },
+    }
 
     _handleEmojiSelect(e) {
       this._setEmoji(e.detail.selected.dataset.value);
-    },
+    }
 
     _setEmoji(text) {
       const colonIndex = this._colonIndex;
@@ -193,12 +203,13 @@
       this.$.textarea.selectionEnd = colonIndex + 1;
       this.$.reporting.reportInteraction('select-emoji');
       this._resetEmojiDropdown();
-    },
+    }
 
     _getText(value) {
       return this.text.substr(0, this._colonIndex || 0) +
           value + this.text.substr(this.$.textarea.selectionStart);
-    },
+    }
+
     /**
      * Uses a hidden element with the same width and styling of the textarea and
      * the text up until the point of interest. Then caratSpan element is added
@@ -214,17 +225,17 @@
       this.$.hiddenText.appendChild(caratSpan);
       this.$.emojiSuggestions.positionTarget = caratSpan;
       this._openEmojiDropdown();
-    },
+    }
 
     _getFontSize() {
       const fontSizePx = getComputedStyle(this).fontSize || '12px';
       return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
           10);
-    },
+    }
 
     _getScrollTop() {
       return document.body.scrollTop;
-    },
+    }
 
     /**
      * _handleKeydown used for key handling in the this.$.textarea AND all child
@@ -271,12 +282,12 @@
         this._updateCaratPosition();
       }
       this.$.textarea.textarea.focus();
-    },
+    }
 
     _openEmojiDropdown() {
       this.$.emojiSuggestions.open();
       this.$.reporting.reportInteraction('open-emoji-dropdown');
-    },
+    }
 
     _formatSuggestions(matchedSuggestions) {
       const suggestions = [];
@@ -286,20 +297,20 @@
         suggestions.push(suggestion);
       }
       this.set('_suggestions', suggestions);
-    },
+    }
 
     _determineSuggestions(emojiText) {
       if (!emojiText.length) {
         this._formatSuggestions(ALL_SUGGESTIONS);
         this.disableEnterKeyForSelectingEmoji = true;
       } else {
-        const matches = ALL_SUGGESTIONS.filter(suggestion => {
-          return suggestion.match.includes(emojiText);
-        }).slice(0, MAX_ITEMS_DROPDOWN);
+        const matches = ALL_SUGGESTIONS
+            .filter(suggestion => suggestion.match.includes(emojiText))
+            .slice(0, MAX_ITEMS_DROPDOWN);
         this._formatSuggestions(matches);
         this.disableEnterKeyForSelectingEmoji = false;
       }
-    },
+    }
 
     _resetEmojiDropdown() {
       // hide and reset the autocomplete dropdown.
@@ -309,11 +320,13 @@
       this.closeDropdown();
       this._colonIndex = null;
       this.$.textarea.textarea.focus();
-    },
+    }
 
     _handleTextChanged(text) {
       this.dispatchEvent(
           new CustomEvent('value-changed', {detail: {value: text}}));
-    },
-  });
+    }
+  }
+
+  customElements.define(GrTextarea.is, GrTextarea);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index b884ecd..699deab 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-textarea</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
index c5de8f4..90e6a08 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -17,31 +17,39 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-tooltip-content',
+  /**
+   * @appliesMixin Gerrit.TooltipMixin
+   * @extends Polymer.Element
+   */
+  class GrTooltipContent extends Polymer.mixinBehaviors( [
+    Gerrit.TooltipBehavior,
+  ], Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element))) {
+    static get is() { return 'gr-tooltip-content'; }
 
-    properties: {
-      title: {
-        type: String,
-        reflectToAttribute: true,
-      },
-      maxWidth: {
-        type: String,
-        reflectToAttribute: true,
-      },
-      positionBelow: {
-        type: Boolean,
-        valye: false,
-        reflectToAttribute: true,
-      },
-      showIcon: {
-        type: Boolean,
-        value: false,
-      },
-    },
+    static get properties() {
+      return {
+        title: {
+          type: String,
+          reflectToAttribute: true,
+        },
+        maxWidth: {
+          type: String,
+          reflectToAttribute: true,
+        },
+        positionBelow: {
+          type: Boolean,
+          valye: false,
+          reflectToAttribute: true,
+        },
+        showIcon: {
+          type: Boolean,
+          value: false,
+        },
+      };
+    }
+  }
 
-    behaviors: [
-      Gerrit.TooltipBehavior,
-    ],
-  });
+  customElements.define(GrTooltipContent.is, GrTooltipContent);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index f9350c6..4276195 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index 75d9c4b..d78d554 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -26,7 +26,7 @@
         --gr-tooltip-arrow-center-offset: 0;
 
         background-color: var(--tooltip-background-color);
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        box-shadow: var(--elevation-level-2);
         color: var(--tooltip-text-color);
         font-size: var(--font-size-small);
         position: absolute;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index fb87b558..6f458d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -17,23 +17,30 @@
 (function() {
   'use strict';
 
-  Polymer({
-    is: 'gr-tooltip',
+  /** @extends Polymer.Element */
+  class GrTooltip extends Polymer.GestureEventListeners(
+      Polymer.LegacyElementMixin(
+          Polymer.Element)) {
+    static get is() { return 'gr-tooltip'; }
 
-    properties: {
-      text: String,
-      maxWidth: {
-        type: String,
-        observer: '_updateWidth',
-      },
-      positionBelow: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-    },
+    static get properties() {
+      return {
+        text: String,
+        maxWidth: {
+          type: String,
+          observer: '_updateWidth',
+        },
+        positionBelow: {
+          type: Boolean,
+          reflectToAttribute: true,
+        },
+      };
+    }
 
     _updateWidth(maxWidth) {
       this.updateStyles({'--tooltip-max-width': maxWidth});
-    },
-  });
+    }
+  }
+
+  customElements.define(GrTooltip.is, GrTooltip);
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index f59f6e1..95f3922 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -17,7 +17,7 @@
 -->
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-storage</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
index 48e488a..239e0fa 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.html
@@ -20,6 +20,7 @@
     'use strict';
 
     /**
+     * @constructor
      * @param {Object} change A change object resulting from a change detail
      *     call that includes revision information.
      */
@@ -33,7 +34,7 @@
      * wherein the revisions are merge commits this will return 2 or potentially
      * more.
      *
-     * @return {Number}
+     * @return {number}
      */
     RevisionInfo.prototype.getMaxParents = function() {
       if (!this._change || !this._change.revisions) {
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
index 7e5810b..187ce35 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>revision-info</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/embed/embed.html b/polygerrit-ui/app/embed/embed.html
deleted file mode 100644
index 64e0137..0000000
--- a/polygerrit-ui/app/embed/embed.html
+++ /dev/null
@@ -1,27 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<script>
-  window.Gerrit = window.Gerrit || {};
-</script>
-<link rel="import" href="/bower_components/polymer/polymer.html">
-<link rel="import" href="../elements/change/gr-change-view/gr-change-view.html">
-<link rel="import" href="../elements/core/gr-search-bar/gr-search-bar.html">
-<link rel="import" href="../elements/diff/gr-diff-view/gr-diff-view.html">
-<link rel="import" href="../elements/change-list/gr-change-list-view/gr-change-list-view.html">
-<link rel="import" href="../elements/change-list/gr-dashboard-view/gr-dashboard-view.html">
-<link rel="import" href="../elements/change-list/gr-embed-dashboard/gr-embed-dashboard.html">
-<link rel="import" href="../styles/themes/app-theme.html">
diff --git a/polygerrit-ui/app/embed/embed_test.html b/polygerrit-ui/app/embed/embed_test.html
deleted file mode 100644
index 1e3f5d7..0000000
--- a/polygerrit-ui/app/embed/embed_test.html
+++ /dev/null
@@ -1,98 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>embed_test</title>
-<script src="/test/common-test-setup.js"></script>
-<script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="embed.html"/>
-
-<script>void(0);</script>
-
-<test-fixture id="change-view">
-  <template>
-    <gr-change-view></gr-change-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="diff-view">
-  <template>
-    <gr-diff-view></gr-diff-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="dashboard-view">
-  <template>
-    <gr-dashboard-view></gr-dashboard-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="change-list-view">
-  <template>
-    <gr-change-list-view></gr-change-list-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="change-list">
-  <template>
-    <gr-change-list></gr-change-list>
-  </template>
-</test-fixture>
-
-<test-fixture id="search-bar">
-  <template>
-    <gr-search-bar></gr-search-bar>
-  </template>
-</test-fixture>
-
-<script>
-  suite('embed test', () => {
-    test('gr-change-view is embedded', () => {
-      const element = fixture('change-view');
-      assert.equal(element.is, 'gr-change-view');
-    });
-
-    test('diff-view is embedded', () => {
-      const element = fixture('diff-view');
-      assert.equal(element.is, 'gr-diff-view');
-    });
-
-    test('dashboard-view is embedded', () => {
-      const element = fixture('dashboard-view');
-      assert.equal(element.is, 'gr-dashboard-view');
-    });
-
-    test('change-list-view is embedded', () => {
-      const element = fixture('change-list-view');
-      assert.equal(element.is, 'gr-change-list-view');
-    });
-
-    test('change-list is embedded', () => {
-      const element = fixture('change-list');
-      assert.equal(element.is, 'gr-change-list');
-    });
-
-    test('search-bar is embedded', () => {
-      const element = fixture('search-bar');
-      assert.equal(element.is, 'gr-search-bar');
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/embed/test.html b/polygerrit-ui/app/embed/test.html
deleted file mode 100644
index 955eaee..0000000
--- a/polygerrit-ui/app/embed/test.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>Embed Test Runner</title>
-<meta charset="utf-8">
-<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/bower_components/web-component-tester/browser.js"></script>
-<script>
-  WCT.loadSuites(['../embed/embed_test.html']);
-</script>
diff --git a/polygerrit-ui/app/embed_test.sh b/polygerrit-ui/app/embed_test.sh
deleted file mode 100755
index 0d8f58f..0000000
--- a/polygerrit-ui/app/embed_test.sh
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/bin/sh
-
-set -ex
-
-t=$(mktemp -d || mktemp -d -t wct-XXXXXXXXXX)
-components=$TEST_SRCDIR/gerrit/polygerrit-ui/app/test_components.zip
-code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/pg_code.zip
-
-echo $t
-unzip -qd $t $components
-unzip -qd $t $code
-# Purge test/ directory contents coming from pg_code.zip.
-rm -rf $t/test
-mkdir -p $t/test
-cp $TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/test.html $t/test/
-
-if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
-    CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
-    FIREFOX_OPTIONS=[\'-headless\']
-else
-    CHROME_OPTIONS=[\'start-maximized\']
-    FIREFOX_OPTIONS=[\'\']
-fi
-
-# For some reason wct tries to install selenium into its node_modules
-# directory on first run. If you've installed into /usr/local and
-# aren't running wct as root, you're screwed. Turning this option off
-# through skipSeleniumInstall seems to still work, so there's that.
-
-# Sauce tests are disabled by default in order to run local tests
-# only.  Run it with (saucelabs.com account required; free for open
-# source): WCT_ARGS='--plugin sauce' ./polygerrit-ui/app/embed_test.sh
-
-cat <<EOF > $t/wct.conf.js
-module.exports = {
-      'suites': ['test'],
-      'webserver': {
-        'pathMappings': [
-          {'/components/bower_components': 'bower_components'}
-        ]
-      },
-      'plugins': {
-        'local': {
-          'skipSeleniumInstall': true,
-          'browserOptions': {
-            'chrome': ${CHROME_OPTIONS},
-            'firefox': ${FIREFOX_OPTIONS}
-          }
-        },
-        'sauce': {
-          'disabled': true,
-          'browsers': [
-            'OS X 10.12/chrome',
-            'Windows 10/chrome',
-            'Linux/firefox',
-            'OS X 10.12/safari',
-            'Windows 10/microsoftedge'
-          ]
-        }
-      }
-    };
-EOF
-
-export PATH="$(dirname $NPM):$PATH"
-
-cd $t
-test -n "${WCT}"
-
-${WCT} ${WCT_ARGS}
diff --git a/polygerrit-ui/app/lint_test.sh b/polygerrit-ui/app/lint_test.sh
index f8e233d8..4e7d8c9 100755
--- a/polygerrit-ui/app/lint_test.sh
+++ b/polygerrit-ui/app/lint_test.sh
@@ -1,7 +1,14 @@
 #!/bin/sh
 
+# DEPRECATED: This file is only used by Gerrit CI for now
+# To run eslint test on FE code, run `npm run eslint` instead.
+# `bazel run lint_test` will be changed to be an alias to
+# `npm run eslint` soon.
+
 set -ex
 
+echo "DEPRECATED: please run `npm run eslint` instead.";
+
 npm_bin=$(which npm) && true
 if [ -z "$npm_bin" ]; then
     echo "NPM must be on the path."
@@ -30,4 +37,4 @@
 # eslint installation.
 npm link eslint eslint-config-google eslint-plugin-html eslint-plugin-jsdoc
 
-${eslint_bin} -c ${UI_PATH}/.eslintrc.json --ext .html,.js ${UI_PATH}
+${eslint_bin} -c ${UI_PATH}/.eslintrc.json --ignore-pattern 'node_modules/' --ignore-pattern 'bower_components/' --ignore-pattern 'scripts/vendor' --ext .html,.js ${UI_PATH}
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
index f6880a1..98cf06f 100755
--- a/polygerrit-ui/app/polylint_test.sh
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -8,13 +8,24 @@
     exit 1
 fi
 
-npx_bin=$(which npx)
-if [[ -z "$npx_bin" ]]; then
-    echo "NPX must be on the path."
-    echo "> npm i -g npx"
+node_bin=$(which node)
+if [[ -z "$node_bin" ]]; then
+    echo "node must be on the path."
+    exit 1
+fi
+
+polymer_bin=$(which polymer)
+if [[ -z "$polymer_bin" ]]; then
+  polymer_bin=$(abs_path ./node_modules/polymer-cli/bin/polymer.js);
+fi
+if [[ -z "$polymer_bin" ]]; then
+    echo "polymer must be set or polymer-cli locally installed (npm install polymer-cli)."
     exit 1
 fi
 
 unzip -o polygerrit-ui/polygerrit_components.bower_components.zip -d polygerrit-ui/app
 
-npx polylint --root polygerrit-ui/app --input elements/gr-app.html --b 'bower_components' --verbose
+#Can't use --root with polymer.json - see https://github.com/Polymer/tools/issues/2616
+#Change current directory to the root folder
+cd polygerrit-ui/app
+$polymer_bin lint --component-dir 'bower_components' --verbose
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
new file mode 100644
index 0000000..411c969
--- /dev/null
+++ b/polygerrit-ui/app/polymer.json
@@ -0,0 +1,14 @@
+{
+  "entrypoint": "elements/gr-app.html",
+  "sources": [
+    "behaviors/**/*",
+    "elements/**/*",
+    "scripts/**/*",
+    "styles/*",
+    "types/**/*"
+  ],
+  "lint": {
+    "rules": ["polymer-2"],
+    "ignoreWarnings": ["deprecated-dom-call"]
+  }
+}
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index e9be18d..76a4fa1 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -48,5 +48,4 @@
       --test_env="DISPLAY=${DISPLAY}" \
       --test_env="WCT_HEADLESS_MODE=${WCT_HEADLESS_MODE}" \
       "$@" \
-      //polygerrit-ui/app:embed_test \
       //polygerrit-ui/app:wct_test
diff --git a/polygerrit-ui/app/samples/bind-parameters.html b/polygerrit-ui/app/samples/bind-parameters.html
index a28c462..e6bf9d1 100644
--- a/polygerrit-ui/app/samples/bind-parameters.html
+++ b/polygerrit-ui/app/samples/bind-parameters.html
@@ -22,6 +22,7 @@
           computed: '_computeExample(revision._number)',
         },
       },
+      /** @override */
       attached() {
         this.plugin.attributeHelper(this).bind(
             'revision', this._onRevisionChanged.bind(this));
diff --git a/polygerrit-ui/app/samples/coverage-plugin.html b/polygerrit-ui/app/samples/coverage-plugin.html
index d1d96a8..fa44a47 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.html
+++ b/polygerrit-ui/app/samples/coverage-plugin.html
@@ -1,6 +1,5 @@
 <dom-module id="coverage-plugin">
   <script>
-
     function populateWithDummyData(coverageData) {
       coverageData['NewFile'] = {
         linesMissingCoverage: [1, 2, 3],
@@ -61,6 +60,8 @@
       }).enableToggleCheckbox('Display Coverage', checkbox => {
         // Checkbox is attached so now add the notifier that will be controlled
         // by the checkbox.
+        // Checkbox will only be added to the file diff page, in the top right
+        // section near the "Diff view".
         annotationApi.addNotifier(notifyFunc => {
           new Promise(resolve => setTimeout(resolve, 3000)).then(() => {
             populateWithDummyData(coverageData);
diff --git a/polygerrit-ui/app/samples/repo-command.html b/polygerrit-ui/app/samples/repo-command.html
index 526d350..5b3ee2c 100644
--- a/polygerrit-ui/app/samples/repo-command.html
+++ b/polygerrit-ui/app/samples/repo-command.html
@@ -7,7 +7,8 @@
             if (repoName !== 'All-Projects') {
               return false;
             }
-          }).onTap(() => {
+          })
+          .onTap(() => {
             alert('Bork, bork!');
           });
 
@@ -30,6 +31,7 @@
     Polymer({
       is: 'repo-command-low',
 
+      /** @override */
       attached() {
         console.log(this.repoName);
         console.log(this.config);
diff --git a/polygerrit-ui/app/samples/some-screen.html b/polygerrit-ui/app/samples/some-screen.html
index da025a2..593b8ab 100644
--- a/polygerrit-ui/app/samples/some-screen.html
+++ b/polygerrit-ui/app/samples/some-screen.html
@@ -42,6 +42,7 @@
       properties: {
         rootUrl: String,
       },
+      /** @override */
       attached() {
         this.rootUrl = `${this.plugin.screenUrl()}`;
       },
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
index 25ca4c5..a35e034 100644
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
+++ b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-display-name-utils</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -35,7 +35,6 @@
       },
     };
 
-
     test('getUserName name only', () => {
       const account = {
         name: 'test-name',
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
index 0266ab9..ac32efa 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-email-suggestions-provider</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
index a95670b..80166cd 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
@@ -31,9 +31,7 @@
           .then(groups => {
             if (!groups) { return []; }
             const keys = Object.keys(groups);
-            return keys.map(key => {
-              return Object.assign({}, groups[key], {name: key});
-            });
+            return keys.map(key => Object.assign({}, groups[key], {name: key}));
           });
     }
 
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
index b60aaa9..6908256 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-group-suggestions-provider</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
index ca3c277..fc6fa1c 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
@@ -18,7 +18,7 @@
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-reviewer-suggestions-provider</title>
-<script src="/test/common-test-setup.js"></script>
+
 <script src="/bower_components/webcomponentsjs/custom-elements-es5-adapter.js"></script>
 
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
@@ -181,9 +181,7 @@
           });
 
           sandbox.stub(GrDisplayNameUtils, '_accountEmail',
-              () => {
-                return '';
-              });
+              () => '');
 
           suggestion = provider.makeSuggestionItem(account3);
           assert.deepEqual(suggestion, {
@@ -193,13 +191,16 @@
         });
 
         test('getSuggestions', done => {
-          provider.getSuggestions().then(reviewers => {
-            // Default is no filtering.
-            assert.equal(reviewers.length, 6);
-            assert.deepEqual(reviewers,
-                [redundantSuggestion1, redundantSuggestion2,
-                  redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-          }).then(done);
+          provider.getSuggestions()
+              .then(reviewers => {
+                // Default is no filtering.
+                assert.equal(reviewers.length, 6);
+                assert.deepEqual(reviewers,
+                    [redundantSuggestion1, redundantSuggestion2,
+                      redundantSuggestion3, suggestion1,
+                      suggestion2, suggestion3]);
+              })
+              .then(done);
         });
 
         test('getSuggestions short circuits when logged out', () => {
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 672c43f..565b9b3 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -105,7 +105,7 @@
    */
   util.querySelector = (el, selector) => {
     let nodes = [el];
-    let element = null;
+    let result = null;
     while (nodes.length) {
       const node = nodes.pop();
 
@@ -113,19 +113,49 @@
       if (!node || !node.querySelector) continue;
 
       // Try find it with native querySelector directly
-      element = node.querySelector(selector);
+      result = node.querySelector(selector);
 
-      if (element) {
+      if (result) {
         break;
-      } else if (node.shadowRoot) {
-        // If shadowHost detected, add the host and its children
-        nodes = nodes.concat(Array.from(node.children));
-        nodes.push(node.shadowRoot);
-      } else {
-        nodes = nodes.concat(Array.from(node.children));
       }
+
+      // Add all nodes with shadowRoot and loop through
+      const allShadowNodes = [...node.querySelectorAll('*')]
+          .filter(child => !!child.shadowRoot)
+          .map(child => child.shadowRoot);
+      nodes = nodes.concat(allShadowNodes);
     }
-    return element;
+    return result;
+  };
+
+  /**
+   * Query selector all dom elements matching with certain selector.
+   *
+   * This is shadow DOM compatible, but only works when selector is within
+   * one shadow host, won't work if your selector is crossing
+   * multiple shadow hosts.
+   *
+   * Note: this can be very expensive, only use when have to.
+   */
+  util.querySelectorAll = (el, selector) => {
+    let nodes = [el];
+    const results = new Set();
+    while (nodes.length) {
+      const node = nodes.pop();
+
+      if (!node || !node.querySelectorAll) continue;
+
+      // Try find all from regular children
+      [...node.querySelectorAll(selector)]
+          .forEach(el => results.add(el));
+
+      // Add all nodes with shadowRoot and loop through
+      const allShadowNodes = [...node.querySelectorAll('*')]
+          .filter(child => !!child.shadowRoot)
+          .map(child => child.shadowRoot);
+      nodes = nodes.concat(allShadowNodes);
+    }
+    return [...results];
   };
 
   window.util = util;
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
index fef3872..84692ba 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.html
@@ -16,7 +16,9 @@
 -->
 <dom-module id="gr-change-metadata-shared-styles">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style>
       section {
         display: table-row;
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
index 834f64a..76e978c 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.html
@@ -25,8 +25,14 @@
 -->
 <dom-module id="gr-change-view-integration-shared-styles">
   <template>
-    <style include="shared-styles"></style>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
     <style>
+      :host {
+        border-top: 1px solid var(--border-color);
+        display: block;
+      }
       .header {
         color: var(--primary-text-color);
         background-color: var(--table-header-background-color);
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 3fe0a72..5133051 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -61,7 +61,6 @@
       }
       .gr-form-styles td,
       .gr-form-styles tfoot th {
-        height: 2em;
         padding: var(--spacing-s) 0;
         vertical-align: middle;
       }
@@ -84,8 +83,7 @@
       .gr-form-styles textarea {
         border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
-        height: 2em;
-        padding: 0 var(--spacing-xs);
+        padding: var(--spacing-s);
       }
       .gr-form-styles td:last-child {
         width: 5em;
@@ -95,25 +93,11 @@
         width: 100%;
       }
       .gr-form-styles iron-autogrow-textarea {
-        border: none;
         height: auto;
-        min-height: 2em;
-        --iron-autogrow-textarea: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          box-sizing: border-box;
-          padding: var(--spacing-s) var(--spacing-xs) 0 var(--spacing-xs);
-        }
+        min-height: 4em;
       }
       .gr-form-styles gr-autocomplete {
-        border: none;
-        --gr-autocomplete: {
-          border: 1px solid var(--border-color);
-          border-radius: var(--border-radius);
-          height: 2em;
-          padding: 0 var(--spacing-xs);
-          width: 14em;
-        }
+        width: 14em;
       }
       @media only screen and (max-width: 40em) {
         .gr-form-styles section {
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 5314741..ce6bead 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -42,23 +42,26 @@
       input {
         background-color: inherit;
         border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
         box-sizing: border-box;
         color: var(--primary-text-color);
         margin: 0;
-        padding: 0;
+        padding: var(--spacing-s);
       }
       iron-autogrow-textarea {
         background-color: inherit;
         color: var(--primary-text-color);
         border: 1px solid var(--border-color);
         border-radius: var(--border-radius);
+        padding: 0;
         box-sizing: border-box;
         /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
            css rule, which prevents overriding the border color. Clear that. */
         -webkit-appearance: none;
 
         --iron-autogrow-textarea: {
-          padding: 4px;
+          box-sizing: border-box;
+          padding: var(--spacing-s);
         };
       }
       a {
diff --git a/polygerrit-ui/app/styles/themes/app-theme.html b/polygerrit-ui/app/styles/themes/app-theme.html
index bb477c2..fc7478b 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.html
+++ b/polygerrit-ui/app/styles/themes/app-theme.html
@@ -36,33 +36,40 @@
   --primary-button-text-color: white;
     /* Used on text color for change list that doesn't need user's attention. */
   --reviewed-text-color: black;
-  --secondary-button-text-color: #212121;
   --tooltip-text-color: white;
   --vote-text-color-recommended: #388e3c;
   --vote-text-color-disliked: #d32f2f;
 
   /* background colors */
+  /* primary background colors */
+  --background-color-primary: #ffffff;
+  --background-color-secondary: #f8f9fa;
+  --background-color-tertiary: #f1f3f4;
+  /* directly derived from primary background colors */
+  --chip-background-color: var(--background-color-tertiary);
+  --default-button-background-color: var(--background-color-primary);
+  --dialog-background-color: var(--background-color-primary);
+  --dropdown-background-color: var(--background-color-primary);
+  --expanded-background-color: var(--background-color-tertiary);
+  --select-background-color: var(--background-color-secondary);
+  --shell-command-background-color: var(--background-color-secondary);
+  --shell-command-decoration-background-color: var(--background-color-tertiary);
+  --table-header-background-color: var(--background-color-secondary);
+  --table-subheader-background-color: var(--background-color-tertiary);
+  --view-background-color: var(--background-color-primary);
+  /* unique background colors */
   --assignee-highlight-color: #fcfad6;
-  --chip-background-color: #eee;
-  --comment-background-color: #fcfad6;
-  --default-button-background-color: white;
-  --dialog-background-color: white;
-  --dropdown-background-color: white;
   --edit-mode-background-color: #ebf5fb;
   --emphasis-color: #fff9c4;
-  --expanded-background-color: #eee;
   --hover-background-color: rgba(161, 194, 250, 0.2);
   --primary-button-background-color: #2a66d9;
-  --secondary-button-background-color: white;
-  --select-background-color: #f8f8f8;
   --selection-background-color: rgba(161, 194, 250, 0.1);
-  --shell-command-background-color: #f5f5f5;
-  --shell-command-decoration-background-color: #ebebeb;
-  --table-header-background-color: #fafafa;
-  --table-subheader-background-color: #eaeaea;
   --tooltip-background-color: #333;
-  --unresolved-comment-background-color: #fcfaa6;
-  --view-background-color: white;
+  /* comment background colors */
+  --comment-background-color: #e8eaed;
+  --robot-comment-background-color: #e8f0fe;
+  --unresolved-comment-background-color: #fef7e0;
+  /* vote background colors */
   --vote-color-approved: #9fcc6b;
   --vote-color-disliked: #f7c4cb;
   --vote-color-neutral: #ebf5fb;
@@ -70,7 +77,7 @@
   --vote-color-rejected: #f7a1ad;
 
   /* misc colors */
-  --border-color: #ddd;
+  --border-color: #dadce0;
 
   /* fonts */
   --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
@@ -102,9 +109,9 @@
   --spacing-xxl: 24px;
 
   /* header and footer */
-  --footer-background-color: #eee;
+  --footer-background-color: var(--background-color-tertiary);
   --footer-border-top: 1px solid var(--border-color);
-  --header-background-color: #eee;
+  --header-background-color: var(--background-color-tertiary);
   --header-border-bottom: 1px solid var(--border-color);
   --header-border-image: '';
   --header-box-shadow: none;
@@ -120,7 +127,7 @@
   --dark-rebased-add-highlight-color: #d7d7f9;
   --dark-rebased-remove-highlight-color: #f7e8b7;
   --dark-remove-highlight-color: #ffcdd2;
-  --diff-blank-background-color: white;
+  --diff-blank-background-color: var(--background-color-secondary);
   --diff-context-control-background-color: #fff7d4;
   --diff-context-control-border-color: #f6e6a5;
   --diff-context-control-color: var(--deemphasized-text-color);
@@ -133,6 +140,8 @@
   --light-rebased-add-highlight-color: #eef;
   --light-remove-add-highlight-color: #fff8dc;
   --light-remove-highlight-color: #ffebee;
+  --coverage-covered: #e0f2f1;
+  --coverage-not-covered: #ffd1a4;
 
   /* syntax colors */
   --syntax-attr-color: #219;
@@ -161,9 +170,21 @@
   --syntax-title-color: #0000c0;
   --syntax-type-color: #2a66d9;
   --syntax-variable-color: var(--primary-text-color);
+
+  /* elevation */
+  --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
+  --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
+  --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
+  --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
+  --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
+
   /* misc */
   --border-radius: 4px;
   --reply-overlay-z-index: 1000;
+
+  /* paper and iron component overrides */
+  --iron-overlay-backdrop-background-color: black;
+  --iron-overlay-backdrop-opacity: 0.32;
   --iron-overlay-backdrop: {
     transition: none;
   };
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 957cc25..56452cb 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -30,39 +30,36 @@
       --primary-text-color: #e8eaed;
       --link-color: #8ab4f8;
       --comment-text-color: var(--primary-text-color);
-      --deemphasized-text-color: #9e9e9e;
+      --deemphasized-text-color: #9aa0a6;
       --default-button-text-color: #8ab4f8;
       --error-text-color: red;
       --primary-button-text-color: var(--primary-text-color);
         /* Used on text color for change list doesn't need user's attention. */
       --reviewed-text-color: #dadce0;
-      --secondary-button-text-color: var(--deemphasized-text-color);
       --tooltip-text-color: white;
       --vote-text-color-recommended: #388e3c;
       --vote-text-color-disliked: #d32f2f;
 
       /* background colors */
+      /* primary background colors */
+      --background-color-primary: #202124;
+      --background-color-secondary: #2f3034;
+      --background-color-tertiary: #3b3d3f;
+      /* directly derived from primary background colors */
+      /*   empty, because inheriting from app-theme is just fine
+      /* unique background colors */
       --assignee-highlight-color: #3a361c;
-      --chip-background-color: #131416;
-      --comment-background-color: #0b162b;
-      --default-button-background-color: #3c4043;
-      --dialog-background-color: #131416;
-      --dropdown-background-color: #131416;
       --edit-mode-background-color: #5c0a36;
       --emphasis-color: #383f4a;
-      --expanded-background-color: #26282b;
       --hover-background-color: rgba(161, 194, 250, 0.2);
       --primary-button-background-color: var(--link-color);
-      --secondary-button-background-color: var(--primary-text-color);
-      --select-background-color: #3c4043;
       --selection-background-color: rgba(161, 194, 250, 0.1);
-      --shell-command-background-color: #5f5f5f;
-      --shell-command-decoration-background-color: #999;
-      --table-header-background-color: #131416;
-      --table-subheader-background-color: rgba(158, 158, 158, 0.24);
       --tooltip-background-color: #111;
-      --unresolved-comment-background-color: #385a9a;
-      --view-background-color: #131416;
+      /* comment background colors */
+      --comment-background-color: #303134;
+      --robot-comment-background-color: #4B5058;
+      --unresolved-comment-background-color: #545146;
+      /* vote background colors */
       --vote-color-approved: #7fb66b;
       --vote-color-disliked: #bf6874;
       --vote-color-neutral: #597280;
@@ -78,9 +75,9 @@
       /* spacing */
 
       /* header and footer */
-      --footer-background-color: #131416;
+      --footer-background-color: var(--background-color-tertiary);
       --footer-border-top: 1px solid var(--border-color);
-      --header-background-color: #3c4043;
+      --header-background-color: var(--background-color-tertiary);
       --header-border-bottom: 1px solid var(--border-color);
       --header-padding: 0 var(--spacing-l);
       --header-text-color: var(--primary-text-color);
@@ -90,8 +87,8 @@
       --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
       --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
       --dark-remove-highlight-color: #62110f;
-      --diff-blank-background-color: #212121;
-      --diff-context-control-background-color: #131416;
+      --diff-blank-background-color: var(--background-color-secondary);
+      --diff-context-control-background-color: #333311;
       --diff-context-control-border-color: var(--border-color);
       --diff-context-control-color: var(--deemphasized-text-color);
       --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
@@ -103,6 +100,8 @@
       --light-rebased-add-highlight-color: #487165;
       --light-remove-add-highlight-color: #2f3f2f;
       --light-remove-highlight-color: #320404;
+      --coverage-covered: #112826;
+      --coverage-not-covered: #6b3600;
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
@@ -134,6 +133,9 @@
 
       /* misc */
 
+      /* paper and iron component overrides */
+      --iron-overlay-backdrop-background-color: white;
+
       /* rules applied to <html> */
       background-color: var(--view-background-color);
     }
diff --git a/polygerrit-ui/app/template_test.sh b/polygerrit-ui/app/template_test.sh
index 2782b65..d42f23f 100755
--- a/polygerrit-ui/app/template_test.sh
+++ b/polygerrit-ui/app/template_test.sh
@@ -1,5 +1,11 @@
 #!/bin/bash
 
+# TODO(dmfilippov): Update template_test to support Polymer 2/Polymer 3 or delete it completely
+# The following line temporary disable template tests. Existing implementation doesn't compatible
+# with Polymer 2 & 3 class-based components. Polymer linter makes some checks regarding
+# templates and binding, but not all.
+exit 0
+
 set -ex
 
 node_bin=$(which node) && true
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index d715d7d..5592825 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -78,7 +78,8 @@
           if (joinedErrors) {
             process.exit(1);
           }
-        }).catch(e => {
+        })
+        .catch(e => {
           console.error(e);
           process.exit(1);
         });
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
index c1d8bbd..ec89c87 100644
--- a/polygerrit-ui/app/test/common-test-setup.html
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -29,8 +29,8 @@
         // This will cause the test to fail if there is a data binding
         // violation.
         throw new Error(
-            'polymer-resin violation: ' + fmt
-            + JSON.stringify(args));
+            'polymer-resin violation: ' + fmt +
+          JSON.stringify(args));
       }
     },
     safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
deleted file mode 100644
index 7ceff7e..0000000
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Helps looking up the proper iron-input element during the Polymer 2
- * transition. Polymer 2 uses the <iron-input> element, while Polymer 1 uses
- * the nested <input is="iron-input"> element.
- */
-window.ironInput = function(element) {
-  return Polymer.dom(element).querySelector(
-      Polymer.Element ? 'iron-input' : 'input[is=iron-input]');
-};
diff --git a/polygerrit-ui/app/test/functional/test.js b/polygerrit-ui/app/test/functional/test.js
index d394487..ae572af 100644
--- a/polygerrit-ui/app/test/functional/test.js
+++ b/polygerrit-ui/app/test/functional/test.js
@@ -11,15 +11,11 @@
 describe('example ', () => {
   let driver;
 
-  beforeAll(() => {
-    return setup().then(d => driver = d);
-  });
+  beforeAll(() => setup().then(d => driver = d));
 
-  afterAll(() => {
-    return cleanup();
-  });
+  afterAll(() => cleanup());
 
-  it('should update title', () => {
-    return driver.wait(until.titleIs('status:open · Gerrit Code Review'), 5000);
-  });
+  it('should update title', () => driver.wait(
+      until.titleIs('status:open · Gerrit Code Review'), 5000
+  ));
 });
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index cca7d04..26078cd 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -117,6 +117,7 @@
     'diff/gr-diff-view/gr-diff-view_test.html',
     'diff/gr-diff/gr-diff-group_test.html',
     'diff/gr-diff/gr-diff_test.html',
+    'diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html',
     'diff/gr-patch-range-select/gr-patch-range-select_test.html',
     'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
diff --git a/polygerrit-ui/app/types/polymer-behaviors.js b/polygerrit-ui/app/types/polymer-behaviors.js
new file mode 100644
index 0000000..18e7ad9
--- /dev/null
+++ b/polygerrit-ui/app/types/polymer-behaviors.js
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * For the purposes of template type checking, externs should be added for
+ * anything set on the window object. Note that sub-properties of these
+ * declared properties are considered something separate.
+ *
+ * This file is only for template type checking, not used in Gerrit code.
+ */
+
+/* eslint-disable no-var */
+/* eslint-disable no-unused-vars */
+
+function PolymerMixins() {
+  // This function must not be called.
+  // Due to an issue in polymer linter the linter can't
+  // process correctly some behaviors from Polymer library.
+  // To workaround this issue, here we define a minimal mixin to allow
+  // linter process our code correctly. You can add more properties to mixins
+  // if needed.
+
+  // Important! Use mixins from these file only inside JSDoc comments.
+  // Do not use it in the real code
+
+  /**
+   * @polymer
+   * @mixinFunction
+   * */
+  Polymer.IronFitMixin = base =>
+    class extends base {
+      static get properties() {
+        return {
+          positionTarget: Object,
+        };
+      }
+    };
+}
diff --git a/polygerrit-ui/app/types/types.js b/polygerrit-ui/app/types/types.js
index e17bec8..7c42d76 100644
--- a/polygerrit-ui/app/types/types.js
+++ b/polygerrit-ui/app/types/types.js
@@ -276,4 +276,27 @@
  *    makeSuggestionItem: function(Object): Gerrit.GrSuggestionItem,
  * }}
  */
-Gerrit.GrSuggestionsProvider;
\ No newline at end of file
+Gerrit.GrSuggestionsProvider;
+
+/**
+ * @typedef {{
+ *  patch_set: ?number,
+ *  id: ?string,
+ *  path: ?Object,
+ *  side: ?string,
+ *  parent: ?number,
+ *  line: ?Object,
+ *  in_reply_to: ?string,
+ *  message: ?Object,
+ *  updated: ?string,
+ *  author: ?Object,
+ *  tag: ?Object,
+ *  unresolved: ?boolean,
+ *  robot_id: ?string,
+ *  robot_run_id: ?string,
+ *  url: ?string,
+ *  properties: ?Object,
+ *  fix_suggestions: ?Object,
+ *  }}
+ */
+Gerrit.Comment;
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 1a2d299..ba52ce8 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -39,7 +39,7 @@
 
 var (
 	plugins               = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port                  = flag.String("port", ":8081", "Port to serve HTTP requests on")
+	port                  = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
 	host                  = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
 	scheme                = flag.String("scheme", "https", "URL scheme")
 	cdnPattern            = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
@@ -84,6 +84,9 @@
 		log.Println("Local plugins from", "../plugins")
 	} else {
 		http.HandleFunc("/plugins/", handleProxy)
+		// Serve local plugins from `plugins_`
+		http.Handle("/plugins_/", http.StripPrefix("/plugins_/",
+			http.FileServer(http.Dir("../plugins"))))
 	}
 	log.Println("Serving on port", *port)
 	log.Fatal(http.ListenAndServe(*port, &server{}))
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
index f4ebe90..ebf2c68 100644
--- a/prologtests/examples/BUILD
+++ b/prologtests/examples/BUILD
@@ -1,15 +1,12 @@
 package(default_visibility = ["//visibility:public"])
 
-DUMMY = ["dummy.sh"]
-
-# Enable prologtests on newer Java versions again, when this Bazel bug is fixed:
-# https://github.com/bazelbuild/bazel/issues/9391
 sh_test(
     name = "test_examples",
-    srcs = select({
-        "//:java11": DUMMY,
-        "//:java_next": DUMMY,
-        "//conditions:default": ["run.sh"],
-    }),
-    data = glob(["*.pl"]) + ["//:gerrit.war"],
+    srcs = ["run.sh"],
+    args = ["$(JAVA)"],
+    data = glob(["*.pl"]) + [
+        "//:gerrit.war",
+        "@bazel_tools//tools/jdk:current_host_java_runtime",
+    ],
+    toolchains = ["@bazel_tools//tools/jdk:current_host_java_runtime"],
 )
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh
index 947c153..b2883ebe 100755
--- a/prologtests/examples/run.sh
+++ b/prologtests/examples/run.sh
@@ -1,5 +1,14 @@
 #!/bin/bash
 
+# TODO(davido): Figure out what to do if running alone and not invoked from bazel
+# $1 is equal to the $(JAVABASE)/bin/java make variable
+JAVA=$1
+
+# Checks whether or not the $1 is starting with a slash: '/' and thus considered to be
+# an absolute path. If it is, then it is left as is, if it isn't then "$PWD/ is prepended
+# (in sh_test case it is relative and thus the runfiles directory is prepended).
+[[ "$JAVA" =~ ^(/|[^/]+$) ]] || JAVA="$PWD/$JAVA"
+
 TESTS="t1 t2 t3"
 
 # Note that both t1.pl and t2.pl test code in rules.pl.
@@ -36,7 +45,7 @@
   # Unit tests do not need to define clauses in packages.
   # Use one prolog-shell per unit test, to avoid name collision.
   echo "### Running test ${T}.pl"
-  echo "[$T]." | java -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
+  echo "[$T]." | "${JAVA}" -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
 
   if [ "x$?" != "x0" ]; then
     echo "### Test ${T}.pl failed."
diff --git a/proto/cache.proto b/proto/cache.proto
index 10e0216..8f73388 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -84,7 +84,7 @@
 
   int32 change_id = 2;
 
-  // Next ID: 24
+  // Next ID: 26
   message ChangeColumnsProto {
     string change_key = 1;
 
@@ -124,6 +124,9 @@
 
     int32 revert_of = 22;
     bool has_revert_of = 23;
+
+    string cherry_pick_of = 24;
+    bool has_cherry_pick_of = 25;
   }
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
diff --git a/proto/entities.proto b/proto/entities.proto
index 374b47c..84c7fbd 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -31,7 +31,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.Change.
-// Next ID: 24
+// Next ID: 25
 message Change {
   required Change_Id change_id = 1;
   optional Change_Key change_key = 2;
@@ -51,6 +51,7 @@
   optional bool work_in_progress = 21;
   optional bool review_started = 22;
   optional Change_Id revert_of = 23;
+  optional PatchSet_Id cherry_pick_of = 24;
 
   // Deleted fields, should not be reused:
   reserved 6;    // sortkey
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 3feb1b4..5046a2a 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -27,6 +27,7 @@
   {@param? polyfillCE: ?}
   {@param? polyfillSD: ?}
   {@param? polyfillSC: ?}
+  {@param? useGoogleFonts: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
@@ -69,13 +70,17 @@
   {/if}
 
   // RobotoMono fonts are used in styles/fonts.css
-  // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  {if $useGoogleFonts}
+    <link rel="preload" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700&display=swap" as="style">
+  {else}
+    // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
+    <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+    <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  {/if}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
 
diff --git a/resources/com/google/gerrit/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
index ec20677..3763d8e 100644
--- a/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,4 +1,7 @@
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
+revertSubmissionDefaultMessage = This reverts commit {0}.
+revertSubmissionUserMessage = Revert \"{0}\"\n\n{1}
+revertSubmissionOfRevertSubmissionUserMessage = Revert^{0} \"{1}\"\n\n{2}
 
 reviewerCantSeeChange = {0} does not have permission to see this change
 reviewerInvalid = {0} is not a valid user identifier
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index e88c424..16c5c8d 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -64,6 +64,7 @@
 {/template}
 
 {template .InboundEmailRejection_COMMENT_REJECTED kind="text"}
-  Gerrit Code Review rejected one or more comments because they did not pass validation.
+  Gerrit Code Review rejected one or more comments because they did not pass validation, or
+  because the maximum number of comments per change would be exceeded.
   {call .InboundEmailRejectionFooter /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index e17508d..8762e10 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -81,7 +81,8 @@
 
 {template .InboundEmailRejectionHtml_COMMENT_REJECTED}
   <p>
-    Gerrit Code Review rejected one or more comments because they did not pass validation.
+    Gerrit Code Review rejected one or more comments because they did not pass validation, or
+    because the maximum number of comments per change would be exceeded.
   </p>
   {call .InboundEmailRejectionFooterHtml /}
 {/template}
diff --git a/tools/BUILD b/tools/BUILD
index 569685c..2d702fe 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -45,6 +45,7 @@
         "-Xep:ExpectedExceptionChecker:ERROR",
         "-Xep:Finally:ERROR",
         "-Xep:FloatingPointLiteralPrecision:ERROR",
+        "-Xep:FormatStringAnnotation:ERROR",
         "-Xep:FragmentInjection:ERROR",
         "-Xep:FragmentNotInstantiable:ERROR",
         "-Xep:FunctionalInterfaceClash:ERROR",
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index f3c4646..1e7ec96 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -1,7 +1,7 @@
 def documentation_attributes():
     return [
         "toc2",
-        'newline="\\n"',
+        "newline=\"\\n\"",
         'asterisk="&#42;"',
         'plus="&#43;"',
         'caret="&#94;"',
diff --git a/tools/bzl/java.bzl b/tools/bzl/java.bzl
deleted file mode 100644
index 8996b69..0000000
--- a/tools/bzl/java.bzl
+++ /dev/null
@@ -1,28 +0,0 @@
-# Copyright (C) 2016 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# Syntactic sugar for native java_library() rule:
-#   accept exported_deps attributes
-
-load("@rules_java//java:defs.bzl", "java_library")
-
-def java_library2(deps = [], exported_deps = [], exports = [], **kwargs):
-    if exported_deps:
-        deps = deps + exported_deps
-        exports = exports + exported_deps
-    java_library(
-        deps = deps,
-        exports = exports,
-        **kwargs
-    )
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 66d7230..3695e16 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -79,12 +79,7 @@
         srcs = srcs,
         outname = s_name,
     )
-    jvm_flags = kwargs.get("jvm_flags", [])
-    jvm_flags = jvm_flags + select({
-        "//:java11": POST_JDK8_OPTS,
-        "//:java_next": POST_JDK8_OPTS,
-        "//conditions:default": [],
-    })
+    jvm_flags = kwargs.get("jvm_flags", []) + POST_JDK8_OPTS
     java_test(
         name = name,
         test_class = s_name,
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 68766a3..2f5447b 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -147,7 +147,6 @@
 
     parts = ctx.attr.artifact.split(":")
 
-    # TODO(davido): Only releases for now, implement handling snapshots
     jar, url = _maven_release(ctx, parts)
 
     binjar = jar + ".jar"
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index ed64d1b..64a6e22 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -21,6 +21,7 @@
         manifest_entries = [],
         dir_name = None,
         target_suffix = "",
+        deploy_env = [],
         **kwargs):
     java_library(
         name = name + "__plugin",
@@ -43,6 +44,7 @@
         runtime_deps = [
             ":%s__plugin" % name,
         ] + static_jars,
+        deploy_env = deploy_env,
         visibility = ["//visibility:public"],
         **kwargs
     )
diff --git a/tools/coverage.sh b/tools/coverage.sh
index 11e50e6..c92d5cf 100755
--- a/tools/coverage.sh
+++ b/tools/coverage.sh
@@ -33,7 +33,7 @@
 cp -r {java,javatests}/* ${destdir}/java
 
 mkdir -p ${destdir}/plugins
-for plugin in `find plugins/ -type d` -maxdepth 1
+for plugin in `find plugins/ -maxdepth 1 -type d`
 do
   mkdir -p ${destdir}/${plugin}/java
   cp -r plugins/*/{java,javatests}/* ${destdir}/${plugin}/java
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index fe2e6ca..4240a9b 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.1.3-SNAPSHOT</version>
+  <version>3.2.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 6ffe873..cf2b080 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.1.3-SNAPSHOT</version>
+  <version>3.2.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index dd5a725..7d3c4f0 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.1.3-SNAPSHOT</version>
+  <version>3.2.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index cc01869..9478283 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.1.3-SNAPSHOT</version>
+  <version>3.2.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index b480943..1210504 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -23,8 +23,8 @@
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.0.7",
-        sha1 = "673899f605f52ca35836673ccfee97154a496a61",
+        artifact = "io.dropwizard.metrics:metrics-core:4.1.2",
+        sha1 = "bba231bbf3024c19e75622ec168821cbbd4261a4",
     )
 
     SSHD_VERS = "2.3.0"
diff --git a/tools/polygerrit-updater/.gitignore b/tools/polygerrit-updater/.gitignore
new file mode 100644
index 0000000..8619a37
--- /dev/null
+++ b/tools/polygerrit-updater/.gitignore
@@ -0,0 +1,3 @@
+/.idea/
+/node_modules/
+/js/
\ No newline at end of file
diff --git a/tools/polygerrit-updater/package-lock.json b/tools/polygerrit-updater/package-lock.json
new file mode 100644
index 0000000..9256997
--- /dev/null
+++ b/tools/polygerrit-updater/package-lock.json
@@ -0,0 +1,18 @@
+{
+  "name": "polygerrit-updater",
+  "version": "1.0.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "@types/node": {
+      "version": "12.7.12",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.12.tgz",
+      "integrity": "sha512-KPYGmfD0/b1eXurQ59fXD1GBzhSQfz6/lKBxkaHX9dKTzjXbK68Zt7yGUxUsCS1jeTy/8aL+d9JEr+S54mpkWQ=="
+    },
+    "typescript": {
+      "version": "3.6.4",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
+      "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg=="
+    }
+  }
+}
diff --git a/tools/polygerrit-updater/package.json b/tools/polygerrit-updater/package.json
new file mode 100644
index 0000000..3609dad
--- /dev/null
+++ b/tools/polygerrit-updater/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "polygerrit-updater",
+  "version": "1.0.0",
+  "description": "Polygerrit source code updater",
+  "scripts": {
+    "compile": "tsc",
+    "convert": "npm run compile && node js/src/index.js"
+  },
+  "author": "",
+  "license": "Apache-2.0",
+  "dependencies": {
+    "@types/node": "^12.7.12",
+    "typescript": "^3.6.4"
+  }
+}
diff --git a/tools/polygerrit-updater/readme.txt b/tools/polygerrit-updater/readme.txt
new file mode 100644
index 0000000..2b2cea8
--- /dev/null
+++ b/tools/polygerrit-updater/readme.txt
@@ -0,0 +1,56 @@
+This folder contains tool to update Polymer components to class based components.
+This is a temporary tools, it will be removed in a few weeks.
+
+How to use this tool: initial steps
+1) Important - Commit and push all your changes. Otherwise, you can loose you work.
+
+2) Ensure, that tools/polygerrit-updater is your current directory
+
+3) Run
+npm install
+
+4) If you want to convert the whole project, run
+npm run convert -- --i \
+  --root ../../polygerrit-ui --src app/elements --r \
+  --exclude app/elements/core/gr-reporting/gr-reporting.js \
+     app/elements/diff/gr-comment-api/gr-comment-api-mock.js \
+     app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+
+You can convert only specific files (can be useful if you want to convert some files in your change)
+npm run convert -- --i \
+  --root ../../polygerrit-ui
+  --src app/elements/file1.js \
+      app/elements/folder/file2.js
+
+4) Search for the following string in all .js files:
+//This file has the following problems with comments:
+
+If you find such string in a .js file - you must manually fix comments in this file.
+(It is expected that you shouldn't have such problems)
+
+5) Go to the gerrit root folder and run
+npm run eslintfix
+
+(If you are doing it for the first time, run the following command before in gerrit root folder:
+npm run install)
+
+Fix error after eslintfix (if exists)
+
+6) If you are doing conversion for the whole project, make the followin changes:
+
+a) Add
+<link rel="import" href="../../../types/polymer-behaviors.js">
+to
+polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+
+b) Update polymer.json with the following rules:
+  "lint": {
+    "rules": ["polymer-2"],
+    "ignoreWarnings": ["deprecated-dom-call"]
+  }
+
+
+
+5) Commit changed files.
+
+6) You can update excluded files later.
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts b/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts
new file mode 100644
index 0000000..b92a6e9
--- /dev/null
+++ b/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts
@@ -0,0 +1,131 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {LegacyLifecycleMethodsArray, LegacyPolymerComponent} from './polymerComponentParser';
+import {LifecycleMethodsBuilder} from './lifecycleMethodsBuilder';
+import {ClassBasedPolymerElement, PolymerElementBuilder} from './polymerElementBuilder';
+import * as codeUtils from '../utils/codeUtils';
+import * as ts from 'typescript';
+
+export class PolymerFuncToClassBasedConverter {
+  public static convert(component: LegacyPolymerComponent): ClassBasedPolymerElement {
+    const legacySettings = component.componentSettings;
+    const reservedDeclarations = legacySettings.reservedDeclarations;
+
+    if(!reservedDeclarations.is) {
+      throw new Error("Legacy component doesn't have 'is' property");
+    }
+    const className = this.generateClassNameFromTagName(reservedDeclarations.is.data);
+    const updater = new PolymerElementBuilder(component, className);
+    updater.addIsAccessor(reservedDeclarations.is.data);
+
+    if(reservedDeclarations.properties) {
+      updater.addPolymerPropertiesAccessor(reservedDeclarations.properties);
+    }
+
+    updater.addMixin("Polymer.Element");
+    updater.addMixin("Polymer.LegacyElementMixin");
+    updater.addMixin("Polymer.GestureEventListeners");
+
+    if(reservedDeclarations._legacyUndefinedCheck) {
+      updater.addMixin("Polymer.LegacyDataMixin");
+    }
+
+    if(reservedDeclarations.behaviors) {
+      updater.addMixin("Polymer.mixinBehaviors", [reservedDeclarations.behaviors.data]);
+      const mixinNames = this.getMixinNamesFromBehaviors(reservedDeclarations.behaviors.data);
+      const jsDocLines = mixinNames.map(mixinName => {
+        return `@appliesMixin ${mixinName}`;
+      });
+      updater.addClassJSDocComments(jsDocLines);
+    }
+
+    if(reservedDeclarations.observers) {
+      updater.addPolymerPropertiesObservers(reservedDeclarations.observers.data);
+    }
+
+    if(reservedDeclarations.keyBindings) {
+      updater.addKeyBindings(reservedDeclarations.keyBindings.data);
+    }
+
+
+    const lifecycleBuilder = new LifecycleMethodsBuilder();
+    if (reservedDeclarations.listeners) {
+      lifecycleBuilder.addListeners(reservedDeclarations.listeners.data, legacySettings.ordinaryMethods);
+    }
+
+    if (reservedDeclarations.hostAttributes) {
+      lifecycleBuilder.addHostAttributes(reservedDeclarations.hostAttributes.data);
+    }
+
+    for(const name of LegacyLifecycleMethodsArray) {
+      const existingMethod = legacySettings.lifecycleMethods.get(name);
+      if(existingMethod) {
+        lifecycleBuilder.addLegacyLifecycleMethod(name, existingMethod)
+      }
+    }
+
+    const newLifecycleMethods = lifecycleBuilder.buildNewMethods();
+    updater.addLifecycleMethods(newLifecycleMethods);
+
+
+    updater.addOrdinaryMethods(legacySettings.ordinaryMethods);
+    updater.addOrdinaryGetAccessors(legacySettings.ordinaryGetAccessors);
+    updater.addOrdinaryShorthandProperties(legacySettings.ordinaryShorthandProperties);
+    updater.addOrdinaryPropertyAssignments(legacySettings.ordinaryPropertyAssignments);
+
+    return updater.build();
+  }
+
+  private static generateClassNameFromTagName(tagName: string) {
+    let result = "";
+    let nextUppercase = true;
+    for(const ch of tagName) {
+      if (ch === '-') {
+        nextUppercase = true;
+        continue;
+      }
+      result += nextUppercase ? ch.toUpperCase() : ch;
+      nextUppercase = false;
+    }
+    return result;
+  }
+
+  private static getMixinNamesFromBehaviors(behaviors: ts.ArrayLiteralExpression): string[] {
+    return behaviors.elements.map((expression) => {
+      const propertyAccessExpression = codeUtils.assertNodeKind(expression, ts.SyntaxKind.PropertyAccessExpression) as ts.PropertyAccessExpression;
+      const namespaceName = codeUtils.assertNodeKind(propertyAccessExpression.expression, ts.SyntaxKind.Identifier) as ts.Identifier;
+      const behaviorName = propertyAccessExpression.name;
+      if(namespaceName.text === 'Gerrit') {
+        let behaviorNameText = behaviorName.text;
+        const suffix = 'Behavior';
+        if(behaviorNameText.endsWith(suffix)) {
+          behaviorNameText =
+              behaviorNameText.substr(0, behaviorNameText.length - suffix.length);
+        }
+        const mixinName = behaviorNameText + 'Mixin';
+        return `${namespaceName.text}.${mixinName}`
+      } else if(namespaceName.text === 'Polymer') {
+        let behaviorNameText = behaviorName.text;
+        if(behaviorNameText === "IronFitBehavior") {
+          return "Polymer.IronFitMixin";
+        } else if(behaviorNameText === "IronOverlayBehavior") {
+          return "";
+        }
+        throw new Error(`Unsupported behavior: ${propertyAccessExpression.getText()}`);
+      }
+      throw new Error(`Unsupported behavior name ${expression.getFullText()}`)
+    }).filter(name => name.length > 0);
+  }
+}
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts b/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts
new file mode 100644
index 0000000..57b7b8d
--- /dev/null
+++ b/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts
@@ -0,0 +1,74 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as ts from 'typescript';
+import * as codeUtils from '../utils/codeUtils'
+import {LegacyPolymerComponent} from './polymerComponentParser';
+import {ClassBasedPolymerElement} from './polymerElementBuilder';
+
+export class LegacyPolymerFuncReplaceResult {
+  public constructor(
+      private readonly transformationResult: ts.TransformationResult<ts.SourceFile>,
+      public readonly leadingComments: string[]) {
+  }
+  public get file(): ts.SourceFile {
+    return this.transformationResult.transformed[0];
+  }
+  public dispose() {
+    this.transformationResult.dispose();
+  }
+
+}
+
+export class LegacyPolymerFuncReplacer {
+  private readonly callStatement: ts.ExpressionStatement;
+  private readonly parentBlock: ts.Block;
+  private readonly callStatementIndexInBlock: number;
+  public constructor(private readonly legacyComponent: LegacyPolymerComponent) {
+    this.callStatement = codeUtils.assertNodeKind(legacyComponent.polymerFuncCallExpr.parent, ts.SyntaxKind.ExpressionStatement);
+    this.parentBlock = codeUtils.assertNodeKind(this.callStatement.parent, ts.SyntaxKind.Block);
+    this.callStatementIndexInBlock = this.parentBlock.statements.indexOf(this.callStatement);
+    if(this.callStatementIndexInBlock < 0) {
+      throw new Error("Internal error! Couldn't find statement in its own parent");
+    }
+  }
+  public replace(classBasedElement: ClassBasedPolymerElement): LegacyPolymerFuncReplaceResult {
+    const classDeclarationWithComments = this.appendLeadingCommentToClassDeclaration(classBasedElement.classDeclaration);
+    return new LegacyPolymerFuncReplaceResult(
+        this.replaceLegacyPolymerFunction(classDeclarationWithComments.classDeclarationWithCommentsPlaceholder, classBasedElement.componentRegistration),
+        classDeclarationWithComments.leadingComments);
+  }
+  private appendLeadingCommentToClassDeclaration(classDeclaration: ts.ClassDeclaration): {classDeclarationWithCommentsPlaceholder: ts.ClassDeclaration, leadingComments: string[]} {
+    const text = this.callStatement.getFullText();
+    let classDeclarationWithCommentsPlaceholder = classDeclaration;
+    const leadingComments: string[] = [];
+    ts.forEachLeadingCommentRange(text, 0, (pos, end, kind, hasTrailingNewLine) => {
+      classDeclarationWithCommentsPlaceholder = codeUtils.addReplacableCommentBeforeNode(classDeclarationWithCommentsPlaceholder, String(leadingComments.length));
+      leadingComments.push(text.substring(pos, end));
+    });
+    return {
+      classDeclarationWithCommentsPlaceholder: classDeclarationWithCommentsPlaceholder,
+      leadingComments: leadingComments
+    }
+  }
+  private replaceLegacyPolymerFunction(classDeclaration: ts.ClassDeclaration, componentRegistration: ts.ExpressionStatement): ts.TransformationResult<ts.SourceFile> {
+    const newStatements = Array.from(this.parentBlock.statements);
+    newStatements.splice(this.callStatementIndexInBlock, 1, classDeclaration, componentRegistration);
+
+    const updatedBlock = ts.getMutableClone(this.parentBlock);
+    updatedBlock.statements = ts.createNodeArray(newStatements);
+    return codeUtils.replaceNode(this.legacyComponent.parsedFile, this.parentBlock, updatedBlock);
+
+  }
+}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts b/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts
new file mode 100644
index 0000000..e9e13f5
--- /dev/null
+++ b/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts
@@ -0,0 +1,140 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as ts from 'typescript';
+import * as codeUtils from '../utils/codeUtils';
+import {LegacyLifecycleMethodName, OrdinaryMethods} from './polymerComponentParser';
+
+interface LegacyLifecycleMethodContent {
+  codeAtMethodStart: ts.Statement[];
+  existingMethod?: ts.MethodDeclaration;
+  codeAtMethodEnd: ts.Statement[];
+}
+
+export interface LifecycleMethod {
+  originalPos: number;//-1 - no original method exists
+  method: ts.MethodDeclaration;
+  name: LegacyLifecycleMethodName;
+}
+
+export class LifecycleMethodsBuilder {
+  private readonly methods: Map<LegacyLifecycleMethodName, LegacyLifecycleMethodContent> = new Map();
+
+  private getMethodContent(name: LegacyLifecycleMethodName): LegacyLifecycleMethodContent {
+    if(!this.methods.has(name)) {
+      this.methods.set(name, {
+        codeAtMethodStart: [],
+        codeAtMethodEnd: []
+      });
+    }
+    return this.methods.get(name)!;
+  }
+
+  public addListeners(legacyListeners: ts.ObjectLiteralExpression, legacyOrdinaryMethods: OrdinaryMethods) {
+    for(const listener of legacyListeners.properties) {
+      const propertyAssignment = codeUtils.assertNodeKind(listener, ts.SyntaxKind.PropertyAssignment) as ts.PropertyAssignment;
+      if(!propertyAssignment.name) {
+        throw new Error("Listener must have event name");
+      }
+      let eventNameLiteral: ts.StringLiteral;
+      let commentsToRestore: string[] = [];
+      if(propertyAssignment.name.kind === ts.SyntaxKind.StringLiteral) {
+        //We don't loose comment in this case, because we keep literal as is
+        eventNameLiteral = propertyAssignment.name;
+      } else if(propertyAssignment.name.kind === ts.SyntaxKind.Identifier) {
+        eventNameLiteral = ts.createStringLiteral(propertyAssignment.name.text);
+        commentsToRestore = codeUtils.getLeadingComments(propertyAssignment);
+      } else {
+        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.name.kind]}`);
+      }
+
+      const handlerLiteral = codeUtils.assertNodeKind(propertyAssignment.initializer, ts.SyntaxKind.StringLiteral) as ts.StringLiteral;
+      const handlerImpl = legacyOrdinaryMethods.get(handlerLiteral.text);
+      if(!handlerImpl) {
+        throw new Error(`Can't find event handler '${handlerLiteral.text}'`);
+      }
+      const eventHandlerAccess = ts.createPropertyAccess(ts.createThis(), handlerLiteral.text);
+      //ts.forEachChild(handler)
+      const args: ts.Identifier[] = handlerImpl.parameters.map((arg) => codeUtils.assertNodeKind(arg.name, ts.SyntaxKind.Identifier));
+      const eventHandlerCall = ts.createCall(eventHandlerAccess, [], args);
+      let arrowFunc = ts.createArrowFunction([], [], handlerImpl.parameters, undefined, undefined, eventHandlerCall);
+      arrowFunc = codeUtils.addNewLineBeforeNode(arrowFunc);
+
+      const methodContent = this.getMethodContent("created");
+      //See https://polymer-library.polymer-project.org/3.0/docs/devguide/gesture-events for a list of events
+      if(["down", "up", "tap", "track"].indexOf(eventNameLiteral.text) >= 0) {
+        const methodCall = ts.createCall(codeUtils.createNameExpression("Polymer.Gestures.addListener"), [], [ts.createThis(), eventNameLiteral, arrowFunc]);
+        methodContent.codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
+      }
+      else {
+        let methodCall = ts.createCall(ts.createPropertyAccess(ts.createThis(), "addEventListener"), [], [eventNameLiteral, arrowFunc]);
+        methodCall = codeUtils.restoreLeadingComments(methodCall, commentsToRestore);
+        methodContent.codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
+      }
+    }
+  }
+
+  public addHostAttributes(legacyHostAttributes: ts.ObjectLiteralExpression) {
+    for(const listener of legacyHostAttributes.properties) {
+      const propertyAssignment = codeUtils.assertNodeKind(listener, ts.SyntaxKind.PropertyAssignment) as ts.PropertyAssignment;
+      if(!propertyAssignment.name) {
+        throw new Error("Listener must have event name");
+      }
+      let attributeNameLiteral: ts.StringLiteral;
+      if(propertyAssignment.name.kind === ts.SyntaxKind.StringLiteral) {
+        attributeNameLiteral = propertyAssignment.name;
+      } else if(propertyAssignment.name.kind === ts.SyntaxKind.Identifier) {
+        attributeNameLiteral = ts.createStringLiteral(propertyAssignment.name.text);
+      } else {
+        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.name.kind]}`);
+      }
+      let attributeValueLiteral: ts.StringLiteral | ts.NumericLiteral;
+      if(propertyAssignment.initializer.kind === ts.SyntaxKind.StringLiteral) {
+        attributeValueLiteral = propertyAssignment.initializer as ts.StringLiteral;
+      } else if(propertyAssignment.initializer.kind === ts.SyntaxKind.NumericLiteral) {
+        attributeValueLiteral = propertyAssignment.initializer as ts.NumericLiteral;
+      } else {
+        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.initializer.kind]}`);
+      }
+      const methodCall = ts.createCall(ts.createPropertyAccess(ts.createThis(), "_ensureAttribute"), [], [attributeNameLiteral, attributeValueLiteral]);
+      this.getMethodContent("ready").codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
+    }
+  }
+
+  public addLegacyLifecycleMethod(name: LegacyLifecycleMethodName, method: ts.MethodDeclaration) {
+    const content = this.getMethodContent(name);
+    if(content.existingMethod) {
+      throw new Error(`Legacy lifecycle method ${name} already added`);
+    }
+    content.existingMethod = method;
+  }
+
+  public buildNewMethods(): LifecycleMethod[] {
+    const result = [];
+    for(const [name, content] of this.methods) {
+      const newMethod = this.createLifecycleMethod(name, content.existingMethod, content.codeAtMethodStart, content.codeAtMethodEnd);
+      if(!newMethod) continue;
+      result.push({
+        name,
+        originalPos: content.existingMethod ? content.existingMethod.pos : -1,
+        method: newMethod
+      })
+    }
+    return result;
+  }
+
+  private createLifecycleMethod(name: string, methodDecl: ts.MethodDeclaration | undefined, codeAtStart: ts.Statement[], codeAtEnd: ts.Statement[]): ts.MethodDeclaration | undefined {
+    return codeUtils.createMethod(name, methodDecl, codeAtStart, codeAtEnd, true);
+  }
+}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts b/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts
new file mode 100644
index 0000000..6006608
--- /dev/null
+++ b/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts
@@ -0,0 +1,301 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as ts from "typescript";
+import * as fs from "fs";
+import * as path from "path";
+import { unexpectedValue } from "../utils/unexpectedValue";
+import * as codeUtils from "../utils/codeUtils";
+import {CommentsParser} from '../utils/commentsParser';
+
+export class LegacyPolymerComponentParser {
+  public constructor(private readonly rootDir: string, private readonly htmlFiles: Set<string>) {
+  }
+  public async parse(jsFile: string): Promise<ParsedPolymerComponent | null> {
+    const sourceFile: ts.SourceFile  = this.parseJsFile(jsFile);
+    const legacyComponent = this.tryParseLegacyComponent(sourceFile);
+    if (legacyComponent) {
+      return legacyComponent;
+    }
+    return null;
+  }
+  private parseJsFile(jsFile: string): ts.SourceFile {
+    return ts.createSourceFile(jsFile, fs.readFileSync(path.resolve(this.rootDir, jsFile)).toString(), ts.ScriptTarget.ES2015, true);
+  }
+
+  private tryParseLegacyComponent(sourceFile: ts.SourceFile): ParsedPolymerComponent | null {
+    const polymerFuncCalls: ts.CallExpression[] = [];
+
+    function addPolymerFuncCall(node: ts.Node) {
+      if(node.kind === ts.SyntaxKind.CallExpression) {
+        const callExpression: ts.CallExpression = node as ts.CallExpression;
+        if(callExpression.expression.kind === ts.SyntaxKind.Identifier) {
+          const identifier = callExpression.expression as ts.Identifier;
+          if(identifier.text === "Polymer") {
+            polymerFuncCalls.push(callExpression);
+          }
+        }
+      }
+      ts.forEachChild(node, addPolymerFuncCall);
+    }
+
+    addPolymerFuncCall(sourceFile);
+
+
+    if (polymerFuncCalls.length === 0) {
+      return null;
+    }
+    if (polymerFuncCalls.length > 1) {
+      throw new Error("Each .js file must contain only one Polymer component");
+    }
+    const parsedPath = path.parse(sourceFile.fileName);
+    const htmlFullPath = path.format({
+      dir: parsedPath.dir,
+      name: parsedPath.name,
+      ext: ".html"
+    });
+    if (!this.htmlFiles.has(htmlFullPath)) {
+      throw new Error("Legacy .js component dosn't have associated .html file");
+    }
+
+    const polymerFuncCall = polymerFuncCalls[0];
+    if(polymerFuncCall.arguments.length !== 1) {
+      throw new Error("The Polymer function must be called with exactly one parameter");
+    }
+    const argument = polymerFuncCall.arguments[0];
+    if(argument.kind !== ts.SyntaxKind.ObjectLiteralExpression) {
+      throw new Error("The parameter for Polymer function must be ObjectLiteralExpression (i.e. '{...}')");
+    }
+    const infoArg = argument as ts.ObjectLiteralExpression;
+
+    return {
+      jsFile: sourceFile.fileName,
+      htmlFile: htmlFullPath,
+      parsedFile: sourceFile,
+      polymerFuncCallExpr: polymerFuncCalls[0],
+      componentSettings: this.parseLegacyComponentSettings(infoArg),
+    };
+  }
+
+  private parseLegacyComponentSettings(info: ts.ObjectLiteralExpression): LegacyPolymerComponentSettings {
+    const props: Map<string, ts.ObjectLiteralElementLike> = new Map();
+    for(const property of info.properties) {
+      const name = property.name;
+      if (name === undefined) {
+        throw new Error("Property name is not defined");
+      }
+      switch(name.kind) {
+        case ts.SyntaxKind.Identifier:
+        case ts.SyntaxKind.StringLiteral:
+          if (props.has(name.text)) {
+            throw new Error(`Property ${name.text} appears more than once`);
+          }
+          props.set(name.text, property);
+          break;
+        case ts.SyntaxKind.ComputedPropertyName:
+          continue;
+        default:
+          unexpectedValue(ts.SyntaxKind[name.kind]);
+      }
+    }
+
+    if(props.has("_noAccessors")) {
+      throw new Error("_noAccessors is not supported");
+    }
+
+    const legacyLifecycleMethods: LegacyLifecycleMethods = new Map();
+    for(const name of LegacyLifecycleMethodsArray) {
+      const methodDecl = this.getLegacyMethodDeclaration(props, name);
+      if(methodDecl) {
+        legacyLifecycleMethods.set(name, methodDecl);
+      }
+    }
+
+    const ordinaryMethods: OrdinaryMethods = new Map();
+    const ordinaryShorthandProperties: OrdinaryShorthandProperties = new Map();
+    const ordinaryGetAccessors: OrdinaryGetAccessors = new Map();
+    const ordinaryPropertyAssignments: OrdinaryPropertyAssignments = new Map();
+    for(const [name, val] of props) {
+      if(RESERVED_NAMES.hasOwnProperty(name)) continue;
+      switch(val.kind) {
+        case ts.SyntaxKind.MethodDeclaration:
+          ordinaryMethods.set(name, val as ts.MethodDeclaration);
+          break;
+        case ts.SyntaxKind.ShorthandPropertyAssignment:
+          ordinaryShorthandProperties.set(name, val as ts.ShorthandPropertyAssignment);
+          break;
+        case ts.SyntaxKind.GetAccessor:
+          ordinaryGetAccessors.set(name, val as ts.GetAccessorDeclaration);
+          break;
+        case ts.SyntaxKind.PropertyAssignment:
+          ordinaryPropertyAssignments.set(name, val as ts.PropertyAssignment);
+          break;
+        default:
+          throw new Error(`Unsupported element kind: ${ts.SyntaxKind[val.kind]}`);
+      }
+      //ordinaryMethods.set(name, tsUtils.assertNodeKind(val, ts.SyntaxKind.MethodDeclaration) as ts.MethodDeclaration);
+    }
+
+    const eventsComments: string[] = this.getEventsComments(info.getFullText());
+
+    return {
+      reservedDeclarations: {
+        is: this.getStringLiteralValueWithComments(this.getLegacyPropertyInitializer(props, "is")),
+        _legacyUndefinedCheck: this.getBooleanLiteralValueWithComments(this.getLegacyPropertyInitializer(props, "_legacyUndefinedCheck")),
+        properties: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "properties")),
+        behaviors: this.getArrayLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "behaviors")),
+        observers: this.getArrayLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "observers")),
+        listeners: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "listeners")),
+        hostAttributes: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "hostAttributes")),
+        keyBindings: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "keyBindings")),
+      },
+      eventsComments: eventsComments,
+      lifecycleMethods: legacyLifecycleMethods,
+      ordinaryMethods: ordinaryMethods,
+      ordinaryShorthandProperties: ordinaryShorthandProperties,
+      ordinaryGetAccessors: ordinaryGetAccessors,
+      ordinaryPropertyAssignments: ordinaryPropertyAssignments,
+    };
+  }
+
+  private convertLegacyProeprtyInitializer<T>(initializer: LegacyPropertyInitializer | undefined, converter: (exp: ts.Expression) => T): DataWithComments<T> | undefined {
+    if(!initializer) {
+      return undefined;
+    }
+    return {
+      data: converter(initializer.data),
+      leadingComments: initializer.leadingComments,
+    }
+  }
+
+  private getObjectLiteralExpressionWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<ts.ObjectLiteralExpression> | undefined {
+    return this.convertLegacyProeprtyInitializer(initializer,
+        expr => codeUtils.getObjectLiteralExpression(expr));
+  }
+
+  private getStringLiteralValueWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<string> | undefined {
+    return this.convertLegacyProeprtyInitializer(initializer,
+        expr => codeUtils.getStringLiteralValue(expr));
+  }
+
+  private getBooleanLiteralValueWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<boolean> | undefined {
+    return this.convertLegacyProeprtyInitializer(initializer,
+        expr => codeUtils.getBooleanLiteralValue(expr));
+  }
+
+
+  private getArrayLiteralExpressionWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<ts.ArrayLiteralExpression> | undefined {
+    return this.convertLegacyProeprtyInitializer(initializer,
+        expr => codeUtils.getArrayLiteralExpression(expr));
+  }
+
+  private getLegacyPropertyInitializer(props: Map<String, ts.ObjectLiteralElementLike>, propName: string): LegacyPropertyInitializer | undefined {
+    const property = props.get(propName);
+    if (!property) {
+      return undefined;
+    }
+    const assignment = codeUtils.getPropertyAssignment(property);
+    if (!assignment) {
+      return undefined;
+    }
+    const comments: string[] = codeUtils.getLeadingComments(property)
+          .filter(c => !this.isEventComment(c));
+    return {
+      data: assignment.initializer,
+      leadingComments: comments,
+    };
+  }
+
+  private isEventComment(comment: string): boolean {
+    return comment.indexOf('@event') >= 0;
+  }
+
+  private getEventsComments(polymerComponentSource: string): string[] {
+    return CommentsParser.collectAllComments(polymerComponentSource)
+        .filter(c => this.isEventComment(c));
+  }
+
+  private getLegacyMethodDeclaration(props: Map<String, ts.ObjectLiteralElementLike>, propName: string): ts.MethodDeclaration | undefined {
+    const property = props.get(propName);
+    if (!property) {
+      return undefined;
+    }
+    return codeUtils.assertNodeKind(property, ts.SyntaxKind.MethodDeclaration) as ts.MethodDeclaration;
+  }
+
+}
+
+export type ParsedPolymerComponent = LegacyPolymerComponent;
+
+export interface LegacyPolymerComponent {
+  jsFile: string;
+  htmlFile: string;
+  parsedFile: ts.SourceFile;
+  polymerFuncCallExpr: ts.CallExpression;
+  componentSettings: LegacyPolymerComponentSettings;
+}
+
+export interface LegacyReservedDeclarations {
+  is?: DataWithComments<string>;
+  _legacyUndefinedCheck?: DataWithComments<boolean>;
+  properties?: DataWithComments<ts.ObjectLiteralExpression>;
+  behaviors?: DataWithComments<ts.ArrayLiteralExpression>,
+  observers? :DataWithComments<ts.ArrayLiteralExpression>,
+  listeners? :DataWithComments<ts.ObjectLiteralExpression>,
+  hostAttributes?: DataWithComments<ts.ObjectLiteralExpression>,
+  keyBindings?: DataWithComments<ts.ObjectLiteralExpression>,
+}
+
+export const LegacyLifecycleMethodsArray = <const>["beforeRegister", "registered", "created", "ready", "attached" , "detached", "attributeChanged"];
+export type LegacyLifecycleMethodName = typeof LegacyLifecycleMethodsArray[number];
+export type LegacyLifecycleMethods = Map<LegacyLifecycleMethodName, ts.MethodDeclaration>;
+export type OrdinaryMethods = Map<string, ts.MethodDeclaration>;
+export type OrdinaryShorthandProperties = Map<string, ts.ShorthandPropertyAssignment>;
+export type OrdinaryGetAccessors = Map<string, ts.GetAccessorDeclaration>;
+export type OrdinaryPropertyAssignments = Map<string, ts.PropertyAssignment>;
+export type ReservedName = LegacyLifecycleMethodName | keyof LegacyReservedDeclarations;
+export const RESERVED_NAMES: {[x in ReservedName]: boolean} = {
+  attached: true,
+  detached: true,
+  ready: true,
+  created: true,
+  beforeRegister: true,
+  registered: true,
+  attributeChanged: true,
+  is: true,
+  _legacyUndefinedCheck: true,
+  properties: true,
+  behaviors: true,
+  observers: true,
+  listeners: true,
+  hostAttributes: true,
+  keyBindings: true,
+};
+
+export interface LegacyPolymerComponentSettings {
+  reservedDeclarations: LegacyReservedDeclarations;
+  lifecycleMethods: LegacyLifecycleMethods,
+  ordinaryMethods: OrdinaryMethods,
+  ordinaryShorthandProperties: OrdinaryShorthandProperties,
+  ordinaryGetAccessors: OrdinaryGetAccessors,
+  ordinaryPropertyAssignments: OrdinaryPropertyAssignments,
+  eventsComments: string[];
+}
+
+export interface DataWithComments<T> {
+  data: T;
+  leadingComments: string[];
+}
+
+type LegacyPropertyInitializer = DataWithComments<ts.Expression>;
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts b/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts
new file mode 100644
index 0000000..d6e113c
--- /dev/null
+++ b/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts
@@ -0,0 +1,142 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {DataWithComments, LegacyPolymerComponent, LegacyReservedDeclarations, OrdinaryGetAccessors, OrdinaryMethods, OrdinaryPropertyAssignments, OrdinaryShorthandProperties} from './polymerComponentParser';
+import * as ts from 'typescript';
+import * as codeUtils from '../utils/codeUtils';
+import {LifecycleMethod} from './lifecycleMethodsBuilder';
+import {PolymerClassBuilder} from '../utils/polymerClassBuilder';
+import {SyntaxKind} from 'typescript';
+
+export interface ClassBasedPolymerElement {
+  classDeclaration: ts.ClassDeclaration;
+  componentRegistration: ts.ExpressionStatement;
+  eventsComments: string[];
+  generatedComments: string[];
+}
+
+export class PolymerElementBuilder {
+  private readonly reservedDeclarations: LegacyReservedDeclarations;
+  private readonly classBuilder: PolymerClassBuilder;
+  private mixins: ts.ExpressionWithTypeArguments | null;
+
+  public constructor(private readonly legacyComponent: LegacyPolymerComponent, className: string) {
+    this.reservedDeclarations = legacyComponent.componentSettings.reservedDeclarations;
+    this.classBuilder = new PolymerClassBuilder(className);
+    this.mixins = null;
+  }
+
+  public addIsAccessor(tagName: string) {
+    this.classBuilder.addIsAccessor(this.createIsAccessor(tagName));
+  }
+
+  public addPolymerPropertiesAccessor(legacyProperties: DataWithComments<ts.ObjectLiteralExpression>) {
+    const returnStatement = ts.createReturn(legacyProperties.data);
+    const block = ts.createBlock([returnStatement]);
+    let propertiesAccessor = ts.createGetAccessor(undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "properties", [], undefined, block);
+    if(legacyProperties.leadingComments.length > 0) {
+      propertiesAccessor = codeUtils.restoreLeadingComments(propertiesAccessor, legacyProperties.leadingComments);
+    }
+    this.classBuilder.addPolymerPropertiesAccessor(legacyProperties.data.pos, propertiesAccessor);
+  }
+
+  public addPolymerPropertiesObservers(legacyObservers: ts.ArrayLiteralExpression) {
+    const returnStatement = ts.createReturn(legacyObservers);
+    const block = ts.createBlock([returnStatement]);
+    const propertiesAccessor = ts.createGetAccessor(undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "observers", [], undefined, block);
+
+    this.classBuilder.addPolymerObserversAccessor(legacyObservers.pos, propertiesAccessor);
+  }
+
+  public addKeyBindings(keyBindings: ts.ObjectLiteralExpression) {
+    //In Polymer 2 keyBindings must be a property with get accessor
+    const returnStatement = ts.createReturn(keyBindings);
+    const block = ts.createBlock([returnStatement]);
+    const keyBindingsAccessor = ts.createGetAccessor(undefined, [], "keyBindings", [], undefined, block);
+
+    this.classBuilder.addGetAccessor(keyBindings.pos, keyBindingsAccessor);
+  }
+  public addOrdinaryMethods(ordinaryMethods: OrdinaryMethods) {
+    for(const [name, method] of ordinaryMethods) {
+      this.classBuilder.addMethod(method.pos, method);
+    }
+  }
+
+  public addOrdinaryGetAccessors(ordinaryGetAccessors: OrdinaryGetAccessors) {
+    for(const [name, accessor] of ordinaryGetAccessors) {
+      this.classBuilder.addGetAccessor(accessor.pos, accessor);
+    }
+  }
+
+  public addOrdinaryShorthandProperties(ordinaryShorthandProperties: OrdinaryShorthandProperties) {
+    for (const [name, property] of ordinaryShorthandProperties) {
+      this.classBuilder.addClassFieldInitializer(property.name, property.name);
+    }
+  }
+
+  public addOrdinaryPropertyAssignments(ordinaryPropertyAssignments: OrdinaryPropertyAssignments) {
+    for (const [name, property] of ordinaryPropertyAssignments) {
+      const propertyName = codeUtils.assertNodeKind(property.name, ts.SyntaxKind.Identifier) as ts.Identifier;
+      this.classBuilder.addClassFieldInitializer(propertyName, property.initializer);
+    }
+  }
+
+  public addMixin(name: string, mixinArguments?: ts.Expression[]) {
+    let fullMixinArguments: ts.Expression[] = [];
+    if(mixinArguments) {
+      fullMixinArguments.push(...mixinArguments);
+    }
+    if(this.mixins) {
+      fullMixinArguments.push(this.mixins.expression);
+    }
+    if(fullMixinArguments.length > 0) {
+      this.mixins = ts.createExpressionWithTypeArguments([], ts.createCall(codeUtils.createNameExpression(name), [], fullMixinArguments.length > 0 ? fullMixinArguments : undefined));
+    }
+    else {
+      this.mixins = ts.createExpressionWithTypeArguments([], codeUtils.createNameExpression(name));
+    }
+  }
+
+  public addClassJSDocComments(lines: string[]) {
+    this.classBuilder.addClassJSDocComments(lines);
+  }
+
+  public build(): ClassBasedPolymerElement {
+    if(this.mixins) {
+      this.classBuilder.setBaseType(this.mixins);
+    }
+    const className = this.classBuilder.className;
+    const callExpression = ts.createCall(ts.createPropertyAccess(ts.createIdentifier("customElements"), "define"), undefined, [ts.createPropertyAccess(ts.createIdentifier(className), "is"), ts.createIdentifier(className)]);
+    const classBuilderResult = this.classBuilder.build();
+    return {
+      classDeclaration: classBuilderResult.classDeclaration,
+      generatedComments: classBuilderResult.generatedComments,
+      componentRegistration: ts.createExpressionStatement(callExpression),
+      eventsComments: this.legacyComponent.componentSettings.eventsComments,
+    };
+  }
+
+  private createIsAccessor(tagName: string): ts.GetAccessorDeclaration {
+    const returnStatement = ts.createReturn(ts.createStringLiteral(tagName));
+    const block = ts.createBlock([returnStatement]);
+    const accessor = ts.createGetAccessor([], [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "is", [], undefined, block);
+    return codeUtils.addReplacableCommentAfterNode(accessor, "eventsComments");
+  }
+
+  public addLifecycleMethods(newLifecycleMethods: LifecycleMethod[]) {
+    for(const lifecycleMethod of newLifecycleMethods) {
+      this.classBuilder.addLifecycleMethod(lifecycleMethod.name, lifecycleMethod.originalPos, lifecycleMethod.method);
+    }
+  }
+}
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts b/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts
new file mode 100644
index 0000000..a147f50
--- /dev/null
+++ b/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts
@@ -0,0 +1,248 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {LegacyPolymerComponent} from './polymerComponentParser';
+import * as ts from 'typescript';
+import * as codeUtils from '../utils/codeUtils';
+import * as path from "path";
+import * as fs from "fs";
+import {LegacyPolymerFuncReplaceResult} from './legacyPolymerFuncReplacer';
+import {CommentsParser} from '../utils/commentsParser';
+
+export interface UpdatedFileWriterParameters {
+  out: string;
+  inplace: boolean;
+  writeOutput: boolean;
+  rootDir: string;
+}
+
+interface Replacement {
+  start: number;
+  length: number;
+  newText: string;
+}
+
+const elementRegistrationRegex = /^(\s*)customElements.define\((\w+).is, \w+\);$/m;
+const maxLineLength = 80;
+
+export class UpdatedFileWriter {
+  public constructor(private readonly component: LegacyPolymerComponent, private readonly params: UpdatedFileWriterParameters) {
+  }
+
+  public write(replaceResult: LegacyPolymerFuncReplaceResult, eventsComments: string[], generatedComments: string[]) {
+    const options: ts.PrinterOptions = {
+      removeComments: false,
+      newLine: ts.NewLineKind.LineFeed,
+    };
+    const printer = ts.createPrinter(options);
+    let newContent = codeUtils.applyNewLines(printer.printFile(replaceResult.file));
+    //ts printer doesn't keep original formatting of the file (spacing, new lines, comments, etc...).
+    //The following code tries restore original formatting
+
+    const existingComments = this.collectAllComments(newContent, []);
+
+    newContent = this.restoreEventsComments(newContent, eventsComments, existingComments);
+    newContent = this.restoreLeadingComments(newContent, replaceResult.leadingComments);
+    newContent = this.restoreFormating(printer, newContent);
+    newContent = this.splitLongLines(newContent);
+    newContent = this.addCommentsWarnings(newContent, generatedComments);
+
+    if (this.params.writeOutput) {
+      const outDir = this.params.inplace ? this.params.rootDir : this.params.out;
+      const fullOutPath = path.resolve(outDir, this.component.jsFile);
+      const fullOutDir = path.dirname(fullOutPath);
+      if (!fs.existsSync(fullOutDir)) {
+        fs.mkdirSync(fullOutDir, {
+          recursive: true,
+          mode: fs.lstatSync(this.params.rootDir).mode
+        });
+      }
+      fs.writeFileSync(fullOutPath, newContent);
+    }
+  }
+
+  private restoreEventsComments(content: string, eventsComments: string[], existingComments: Map<string, number>): string {
+    //In some cases Typescript compiler keep existing comments. These comments
+    // must not be restored here
+    eventsComments = eventsComments.filter(c => !existingComments.has(this.getNormalizedComment(c)));
+    return codeUtils.replaceComment(content, "eventsComments", "\n" + eventsComments.join("\n\n") + "\n");
+  }
+
+  private restoreLeadingComments(content: string, leadingComments: string[]): string {
+    return leadingComments.reduce(
+        (newContent, comment, commentIndex) =>
+            codeUtils.replaceComment(newContent, String(commentIndex), comment),
+        content);
+  }
+
+  private restoreFormating(printer: ts.Printer, newContent: string): string {
+    const originalFile = this.component.parsedFile;
+    const newFile = ts.createSourceFile(originalFile.fileName, newContent, originalFile.languageVersion, true, ts.ScriptKind.JS);
+    const textMap = new Map<ts.SyntaxKind, Map<string, Set<string>>>();
+    const comments = new Set<string>();
+    this.collectAllStrings(printer, originalFile, textMap);
+
+    const replacements: Replacement[] = [];
+    this.collectReplacements(printer, newFile, textMap, replacements);
+    replacements.sort((a, b) => b.start - a.start);
+    let result = newFile.getFullText();
+    let prevReplacement: Replacement | null = null;
+    for (const replacement of replacements) {
+      if (prevReplacement) {
+        if (replacement.start + replacement.length > prevReplacement.start) {
+          throw new Error('Internal error! Replacements must not intersect');
+        }
+      }
+      result = result.substring(0, replacement.start) + replacement.newText + result.substring(replacement.start + replacement.length);
+      prevReplacement = replacement;
+    }
+    return result;
+  }
+
+  private splitLongLines(content: string): string {
+    content = content.replace(elementRegistrationRegex, (match, indent, className) => {
+      if (match.length > maxLineLength) {
+        return `${indent}customElements.define(${className}.is,\n` +
+            `${indent}  ${className});`;
+      }
+      else {
+        return match;
+      }
+    });
+
+    return content
+        .replace(
+            "Polymer.LegacyDataMixin(Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element)))",
+            "Polymer.LegacyDataMixin(\nPolymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element)))")
+        .replace(
+            "Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element))",
+            "Polymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element))");
+
+  }
+
+  private addCommentsWarnings(newContent: string, generatedComments: string[]): string {
+    const expectedComments = this.collectAllComments(this.component.parsedFile.getFullText(), generatedComments);
+    const newComments = this.collectAllComments(newContent, []);
+    const commentsWarnings = [];
+    for (const [text, count] of expectedComments) {
+      const newCount = newComments.get(text);
+      if (!newCount) {
+        commentsWarnings.push(`Comment '${text}' is missing in the new content.`);
+      }
+      else if (newCount != count) {
+        commentsWarnings.push(`Comment '${text}' appears ${newCount} times in the new file and ${count} times in the old file.`);
+      }
+    }
+
+    for (const [text, newCount] of newComments) {
+      if (!expectedComments.has(text)) {
+        commentsWarnings.push(`Comment '${text}' appears only in the new content`);
+      }
+    }
+    if (commentsWarnings.length === 0) {
+      return newContent;
+    }
+    let commentsProblemStr = "";
+    if (commentsWarnings.length > 0) {
+      commentsProblemStr = commentsWarnings.join("-----------------------------\n");
+      console.log(commentsProblemStr);
+    }
+
+    return "//This file has the following problems with comments:\n" + commentsProblemStr + "\n" + newContent;
+
+  }
+
+  private collectAllComments(content: string, additionalComments: string[]): Map<string, number> {
+    const comments = CommentsParser.collectAllComments(content);
+    comments.push(...additionalComments);
+    const result = new Map<string, number>();
+    for (const comment of comments) {
+      let normalizedComment = this.getNormalizedComment(comment);
+      const count = result.get(normalizedComment);
+      if (count) {
+        result.set(normalizedComment, count + 1);
+      } else {
+        result.set(normalizedComment, 1);
+      }
+    }
+    return result;
+  }
+
+  private getNormalizedComment(comment: string): string {
+    if(comment.startsWith('/**')) {
+      comment = comment.replace(/^\s+\*/gm, "*");
+    }
+    return comment;
+  }
+
+  private collectAllStrings(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>) {
+    const formattedText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
+    const originalText = node.getFullText();
+    this.addIfNotExists(map, node.kind, formattedText, originalText);
+    ts.forEachChild(node, child => this.collectAllStrings(printer, child, map));
+  }
+
+  private collectReplacements(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>, replacements: Replacement[]) {
+    if(node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.Identifier || node.kind === ts.SyntaxKind.StringLiteral || node.kind === ts.SyntaxKind.NumericLiteral) {
+      return;
+    }
+    const replacement = this.getReplacement(printer, node, map);
+    if(replacement) {
+      replacements.push(replacement);
+      return;
+    }
+    ts.forEachChild(node, child => this.collectReplacements(printer, child, map, replacements));
+  }
+
+  private addIfNotExists(map: Map<ts.SyntaxKind, Map<string, Set<string>>>, kind: ts.SyntaxKind, formattedText: string, originalText: string) {
+    let mapForKind = map.get(kind);
+    if(!mapForKind) {
+      mapForKind = new Map();
+      map.set(kind, mapForKind);
+    }
+
+    let existingOriginalText = mapForKind.get(formattedText);
+    if(!existingOriginalText) {
+      existingOriginalText = new Set<string>();
+      mapForKind.set(formattedText, existingOriginalText);
+      //throw new Error(`Different formatting of the same string exists. Kind: ${ts.SyntaxKind[kind]}.\nFormatting 1:\n${originalText}\nFormatting2:\n${existingOriginalText}\n `);
+    }
+    existingOriginalText.add(originalText);
+  }
+
+  private getReplacement(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>): Replacement | undefined {
+    const replacementsForKind = map.get(node.kind);
+    if(!replacementsForKind) {
+      return;
+    }
+    // Use printer instead of getFullText to "isolate" node content.
+    // node.getFullText returns text with indents from the original file.
+    const newText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile());
+    const originalSet = replacementsForKind.get(newText);
+    if(!originalSet || originalSet.size === 0) {
+      return;
+    }
+    if(originalSet.size >= 2) {
+      console.log(`Multiple replacements possible. Formatting of some lines can be changed`);
+    }
+    const replacementText: string = originalSet.values().next().value;
+    const nodeText = node.getFullText();
+    return {
+      start: node.pos,
+      length: nodeText.length,//Do not use newText here!
+      newText: replacementText,
+    }
+  }
+
+}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/index.ts b/tools/polygerrit-updater/src/index.ts
new file mode 100644
index 0000000..1b7c315
--- /dev/null
+++ b/tools/polygerrit-updater/src/index.ts
@@ -0,0 +1,168 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as fs from "fs";
+import * as path from "path";
+import {LegacyPolymerComponent, LegacyPolymerComponentParser} from './funcToClassConversion/polymerComponentParser';
+import {ClassBasedPolymerElement} from './funcToClassConversion/polymerElementBuilder';
+import {PolymerFuncToClassBasedConverter} from './funcToClassConversion/funcToClassBasedElementConverter';
+import {LegacyPolymerFuncReplacer} from './funcToClassConversion/legacyPolymerFuncReplacer';
+import {UpdatedFileWriter} from './funcToClassConversion/updatedFileWriter';
+import {CommandLineParser} from './utils/commandLineParser';
+
+interface UpdaterParameters {
+  htmlFiles: Set<string>;
+  jsFiles: Set<string>;
+  out: string;
+  inplace: boolean;
+  writeOutput: boolean;
+  rootDir: string;
+}
+
+interface InputFilesFilter {
+  includeDir(path: string): boolean;
+  includeFile(path: string): boolean;
+}
+
+function addFile(filePath: string, params: UpdaterParameters, filter: InputFilesFilter) {
+  const parsedPath = path.parse(filePath);
+  const ext = parsedPath.ext.toLowerCase();
+  const relativePath = path.relative(params.rootDir, filePath);
+  if(!filter.includeFile(relativePath)) return;
+  if(relativePath.startsWith("../")) {
+    throw new Error(`${filePath} is not in rootDir ${params.rootDir}`);
+  }
+  if(ext === ".html") {
+    params.htmlFiles.add(relativePath);
+  } if(ext === ".js") {
+    params.jsFiles.add(relativePath);
+  }
+}
+
+function addDirectory(dirPath: string, params: UpdaterParameters, recursive: boolean, filter: InputFilesFilter): void {
+  const entries = fs.readdirSync(dirPath, {withFileTypes: true});
+  for(const entry of entries) {
+    const dirEnt = entry as fs.Dirent;
+    const fullPath = path.join(dirPath, dirEnt.name);
+    const relativePath = path.relative(params.rootDir, fullPath);
+    if(dirEnt.isDirectory()) {
+      if (!filter.includeDir(relativePath)) continue;
+      if(recursive) {
+        addDirectory(fullPath, params, recursive, filter);
+      }
+    }
+    else if(dirEnt.isFile()) {
+      addFile(fullPath, params, filter);
+    } else {
+      throw Error(`Unsupported dir entry '${entry.name}' in '${fullPath}'`);
+    }
+  }
+}
+
+async function updateLegacyComponent(component: LegacyPolymerComponent, params: UpdaterParameters) {
+  const classBasedElement: ClassBasedPolymerElement = PolymerFuncToClassBasedConverter.convert(component);
+
+  const replacer = new LegacyPolymerFuncReplacer(component);
+  const replaceResult = replacer.replace(classBasedElement);
+  try {
+    const writer = new UpdatedFileWriter(component, params);
+    writer.write(replaceResult, classBasedElement.eventsComments, classBasedElement.generatedComments);
+  }
+  finally {
+    replaceResult.dispose();
+  }
+}
+
+async function main() {
+  const params: UpdaterParameters = await getParams();
+  if(params.jsFiles.size === 0) {
+    console.log("No files found");
+    return;
+  }
+  const legacyPolymerComponentParser = new LegacyPolymerComponentParser(params.rootDir, params.htmlFiles)
+  for(const jsFile of params.jsFiles) {
+    console.log(`Processing ${jsFile}`);
+    const legacyComponent = await legacyPolymerComponentParser.parse(jsFile);
+    if(legacyComponent) {
+      await updateLegacyComponent(legacyComponent, params);
+      continue;
+    }
+  }
+}
+
+interface CommandLineParameters {
+  src: string[];
+  recursive: boolean;
+  excludes: string[];
+  out: string;
+  inplace: boolean;
+  noOutput: boolean;
+  rootDir: string;
+}
+
+async function getParams(): Promise<UpdaterParameters> {
+  const parser = new CommandLineParser({
+    src: CommandLineParser.createStringArrayOption("src", ".js file or folder to process", []),
+    recursive: CommandLineParser.createBooleanOption("r", "process folder recursive", false),
+    excludes: CommandLineParser.createStringArrayOption("exclude", "List of file prefixes to exclude. If relative file path starts with one of the prefixes, it will be excluded", []),
+    out: CommandLineParser.createStringOption("out", "Output folder.", null),
+    rootDir: CommandLineParser.createStringOption("root", "Root directory for src files", "/"),
+    inplace: CommandLineParser.createBooleanOption("i", "Update files inplace", false),
+    noOutput: CommandLineParser.createBooleanOption("noout", "Do everything, but do not write anything to files", false),
+  });
+  const commandLineParams: CommandLineParameters = parser.parse(process.argv) as CommandLineParameters;
+
+  const params: UpdaterParameters = {
+    htmlFiles: new Set(),
+    jsFiles: new Set(),
+    writeOutput: !commandLineParams.noOutput,
+    inplace: commandLineParams.inplace,
+    out: commandLineParams.out,
+    rootDir: path.resolve(commandLineParams.rootDir)
+  };
+
+  if(params.writeOutput && !params.inplace && !params.out) {
+    throw new Error("You should specify output directory (--out directory_name)");
+  }
+
+  const filter = new ExcludeFilesFilter(commandLineParams.excludes);
+  for(const srcPath of commandLineParams.src) {
+    const resolvedPath = path.resolve(params.rootDir, srcPath);
+    if(fs.lstatSync(resolvedPath).isFile()) {
+      addFile(resolvedPath, params, filter);
+    } else {
+      addDirectory(resolvedPath, params, commandLineParams.recursive, filter);
+    }
+  }
+  return params;
+}
+
+class ExcludeFilesFilter implements InputFilesFilter {
+  public constructor(private readonly excludes: string[]) {
+  }
+  includeDir(path: string): boolean {
+    return this.excludes.every(exclude => !path.startsWith(exclude));
+  }
+
+  includeFile(path: string): boolean {
+    return this.excludes.every(exclude => !path.startsWith(exclude));
+  }
+}
+
+main().then(() => {
+  process.exit(0);
+}).catch(e => {
+  console.error(e);
+  process.exit(1);
+});
diff --git a/tools/polygerrit-updater/src/utils/codeUtils.ts b/tools/polygerrit-updater/src/utils/codeUtils.ts
new file mode 100644
index 0000000..53a7f0d
--- /dev/null
+++ b/tools/polygerrit-updater/src/utils/codeUtils.ts
@@ -0,0 +1,183 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as ts from 'typescript';
+import {SyntaxKind} from 'typescript';
+import {Node} from 'typescript';
+
+export function assertNodeKind<T extends U, U extends ts.Node>(node: U, expectedKind: ts.SyntaxKind): T {
+  if (node.kind !== expectedKind) {
+    throw new Error(`Invlid node kind. Expected: ${ts.SyntaxKind[expectedKind]}, actual: ${ts.SyntaxKind[node.kind]}`);
+  }
+  return node as T;
+}
+
+export function assertNodeKindOrUndefined<T extends U, U extends ts.Node>(node: U | undefined, expectedKind: ts.SyntaxKind): T | undefined {
+  if (!node) {
+    return undefined;
+  }
+  return assertNodeKind<T, U>(node, expectedKind);
+}
+
+export function getPropertyAssignment(expression?: ts.ObjectLiteralElementLike): ts.PropertyAssignment | undefined {
+  return assertNodeKindOrUndefined(expression, ts.SyntaxKind.PropertyAssignment);
+}
+
+export function getStringLiteralValue(expression: ts.Expression): string {
+  const literal: ts.StringLiteral = assertNodeKind(expression, ts.SyntaxKind.StringLiteral);
+  return literal.text;
+}
+
+export function getBooleanLiteralValue(expression: ts.Expression): boolean {
+  if (expression.kind === ts.SyntaxKind.TrueKeyword) {
+    return true;
+  }
+  if (expression.kind === ts.SyntaxKind.FalseKeyword) {
+    return false;
+  }
+  throw new Error(`Invalid expression kind - ${expression.kind}`);
+}
+
+export function getObjectLiteralExpression(expression: ts.Expression): ts.ObjectLiteralExpression {
+  return assertNodeKind(expression, ts.SyntaxKind.ObjectLiteralExpression);
+}
+
+export function getArrayLiteralExpression(expression: ts.Expression): ts.ArrayLiteralExpression {
+  return assertNodeKind(expression, ts.SyntaxKind.ArrayLiteralExpression);
+}
+
+export function replaceNode(file: ts.SourceFile, originalNode: ts.Node, newNode: ts.Node): ts.TransformationResult<ts.SourceFile> {
+  const nodeReplacerTransformer: ts.TransformerFactory<ts.SourceFile> = (context: ts.TransformationContext) => {
+    const visitor: ts.Visitor = (node) => {
+      if(node === originalNode) {
+        return newNode;
+      }
+      return ts.visitEachChild(node, visitor, context);
+    };
+
+
+    return source => ts.visitNode(source, visitor);
+  };
+  return ts.transform(file, [nodeReplacerTransformer]);
+}
+
+export type NameExpression = ts.Identifier | ts.ThisExpression | ts.PropertyAccessExpression;
+export function createNameExpression(fullPath: string): NameExpression {
+  const parts = fullPath.split(".");
+  let result: NameExpression = parts[0] === "this" ? ts.createThis() : ts.createIdentifier(parts[0]);
+  for(let i = 1; i < parts.length; i++) {
+    result = ts.createPropertyAccess(result, parts[i]);
+  }
+  return result;
+}
+
+const generatedCommentNewLineAfterText = "-Generated code - new line after - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
+const generatedCommentNewLineBeforeText = "-Generated code - new line-before - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
+const generatedCommentNewLineAfterRegExp = new RegExp("//" + generatedCommentNewLineAfterText, 'g');
+const generatedCommentNewLineBeforeRegExp = new RegExp("//" + generatedCommentNewLineBeforeText + "\n", 'g');
+const replacableCommentText = "- Replacepoint - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
+
+export function addNewLineAfterNode<T extends ts.Node>(node: T): T {
+  const comment = ts.getSyntheticTrailingComments(node);
+  if(comment && comment.some(c => c.text === generatedCommentNewLineAfterText)) {
+    return node;
+  }
+  return ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, generatedCommentNewLineAfterText, true);
+}
+
+export function addNewLineBeforeNode<T extends ts.Node>(node: T): T {
+  const comment = ts.getSyntheticLeadingComments(node);
+  if(comment && comment.some(c => c.text === generatedCommentNewLineBeforeText)) {
+    return node;
+  }
+  return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, generatedCommentNewLineBeforeText, true);
+}
+
+
+export function applyNewLines(text: string): string {
+  return text.replace(generatedCommentNewLineAfterRegExp, "").replace(generatedCommentNewLineBeforeRegExp, "");
+
+}
+export function addReplacableCommentAfterNode<T extends ts.Node>(node: T, name: string): T {
+  return ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, replacableCommentText + name, true);
+}
+
+export function addReplacableCommentBeforeNode<T extends ts.Node>(node: T, name: string): T {
+  return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, replacableCommentText + name, true);
+}
+
+export function replaceComment(text: string, commentName: string, newContent: string): string {
+  return text.replace("//" + replacableCommentText + commentName, newContent);
+}
+
+export function createMethod(name: string, methodDecl: ts.MethodDeclaration | undefined, codeAtStart: ts.Statement[], codeAtEnd: ts.Statement[], callSuperMethod: boolean): ts.MethodDeclaration | undefined {
+  if(!methodDecl && (codeAtEnd.length > 0 || codeAtEnd.length > 0)) {
+    methodDecl = ts.createMethod([], [], undefined, name, undefined, [], [],undefined, ts.createBlock([]));
+  }
+  if(!methodDecl) {
+    return;
+  }
+  if (!methodDecl.body) {
+    throw new Error("Method must have a body");
+  }
+  if(methodDecl.parameters.length > 0) {
+    throw new Error("Methods with parameters are not supported");
+  }
+  let newStatements = [...codeAtStart];
+  if(callSuperMethod) {
+    const superCall: ts.CallExpression = ts.createCall(ts.createPropertyAccess(ts.createSuper(), assertNodeKind(methodDecl.name, ts.SyntaxKind.Identifier) as ts.Identifier), [], []);
+    const superCallExpression = ts.createExpressionStatement(superCall);
+    newStatements.push(superCallExpression);
+  }
+  newStatements.push(...codeAtEnd);
+  const newBody = ts.getMutableClone(methodDecl.body);
+
+  newStatements = newStatements.map(m => addNewLineAfterNode(m));
+  newStatements.splice(codeAtStart.length + 1, 0, ...newBody.statements);
+
+  newBody.statements = ts.createNodeArray(newStatements);
+
+  const newMethod = ts.getMutableClone(methodDecl);
+  newMethod.body = newBody;
+
+  return newMethod;
+}
+
+export function restoreLeadingComments<T extends Node>(node: T, originalComments: string[]): T {
+  if(originalComments.length === 0) {
+    return node;
+  }
+  for(const comment of originalComments) {
+    if(comment.startsWith("//")) {
+      node = ts.addSyntheticLeadingComment(node, SyntaxKind.SingleLineCommentTrivia, comment.substr(2), true);
+    } else if(comment.startsWith("/*")) {
+      if(!comment.endsWith("*/")) {
+        throw new Error(`Not support comment: ${comment}`);
+      }
+      node = ts.addSyntheticLeadingComment(node, SyntaxKind.MultiLineCommentTrivia, comment.substr(2, comment.length - 4), true);
+    } else {
+      throw new Error(`Not supported comment: ${comment}`);
+    }
+  }
+  return node;
+}
+
+export function getLeadingComments(node: ts.Node): string[] {
+  const nodeText = node.getFullText();
+  const commentRanges = ts.getLeadingCommentRanges(nodeText, 0);
+  if(!commentRanges) {
+    return [];
+  }
+  return commentRanges.map(range => nodeText.substring(range.pos, range.end))
+}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/utils/commandLineParser.ts b/tools/polygerrit-updater/src/utils/commandLineParser.ts
new file mode 100644
index 0000000..658b7ff
--- /dev/null
+++ b/tools/polygerrit-updater/src/utils/commandLineParser.ts
@@ -0,0 +1,134 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed un  der the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export class CommandLineParser {
+  public static createStringArrayOption(optionName: string, help: string, defaultValue: string[]): CommandLineArgument {
+    return new StringArrayOption(optionName, help, defaultValue);
+  }
+  public static createBooleanOption(optionName: string, help: string, defaultValue: boolean): CommandLineArgument {
+    return new BooleanOption(optionName, help, defaultValue);
+  }
+  public static createStringOption(optionName: string, help: string, defaultValue: string | null): CommandLineArgument {
+    return new StringOption(optionName, help, defaultValue);
+  }
+
+  public constructor(private readonly argumentTypes: {[name: string]: CommandLineArgument}) {
+  }
+  public parse(argv: string[]): object {
+    const result = Object.assign({});
+    let index = 2; //argv[0] - node interpreter, argv[1] - index.js
+    for(const argumentField in this.argumentTypes) {
+      result[argumentField] = this.argumentTypes[argumentField].getDefaultValue();
+    }
+    while(index < argv.length) {
+      let knownArgument = false;
+      for(const argumentField in this.argumentTypes) {
+        const argumentType = this.argumentTypes[argumentField];
+        const argumentValue = argumentType.tryRead(argv, index);
+        if(argumentValue) {
+          knownArgument = true;
+          index += argumentValue.consumed;
+          result[argumentField] = argumentValue.value;
+          break;
+        }
+      }
+      if(!knownArgument) {
+        throw new Error(`Unknown argument ${argv[index]}`);
+      }
+    }
+    return result;
+  }
+}
+
+interface CommandLineArgumentReadResult {
+  consumed: number;
+  value: any;
+}
+
+export interface CommandLineArgument {
+  getDefaultValue(): any;
+  tryRead(argv: string[], startIndex: number): CommandLineArgumentReadResult | null;
+}
+
+abstract class CommandLineOption implements CommandLineArgument {
+  protected constructor(protected readonly optionName: string, protected readonly help: string, private readonly defaultValue: any) {
+  }
+  public tryRead(argv: string[], startIndex: number): CommandLineArgumentReadResult | null  {
+    if(argv[startIndex] !== "--" + this.optionName) {
+      return null;
+    }
+    const readArgumentsResult = this.readArguments(argv, startIndex + 1);
+    if(!readArgumentsResult) {
+      return null;
+    }
+    readArgumentsResult.consumed++; // Add option name
+    return readArgumentsResult;
+  }
+  public getDefaultValue(): any {
+    return this.defaultValue;
+  }
+
+  protected abstract readArguments(argv: string[], startIndex: number) : CommandLineArgumentReadResult | null;
+}
+
+class StringArrayOption extends CommandLineOption {
+  public constructor(optionName: string, help: string, defaultValue: string[]) {
+    super(optionName, help, defaultValue);
+  }
+
+  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult {
+    const result = [];
+    let index = startIndex;
+    while(index < argv.length) {
+      if(argv[index].startsWith("--")) {
+        break;
+      }
+      result.push(argv[index]);
+      index++;
+    }
+    return {
+      consumed: index - startIndex,
+      value: result
+    }
+  }
+}
+
+class BooleanOption extends CommandLineOption {
+  public constructor(optionName: string, help: string, defaultValue: boolean) {
+    super(optionName, help, defaultValue);
+  }
+
+  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult {
+    return {
+      consumed: 0,
+      value: true
+    }
+  }
+}
+
+class StringOption extends CommandLineOption {
+  public constructor(optionName: string, help: string, defaultValue: string | null) {
+    super(optionName, help, defaultValue);
+  }
+
+  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult | null {
+    if(startIndex >= argv.length) {
+      return null;
+    }
+    return {
+      consumed: 1,
+      value: argv[startIndex]
+    }
+  }
+}
diff --git a/tools/polygerrit-updater/src/utils/commentsParser.ts b/tools/polygerrit-updater/src/utils/commentsParser.ts
new file mode 100644
index 0000000..b849829
--- /dev/null
+++ b/tools/polygerrit-updater/src/utils/commentsParser.ts
@@ -0,0 +1,79 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+enum CommentScannerState {
+  Text,
+  SingleLineComment,
+  MultLineComment
+}
+export class CommentsParser {
+  public static collectAllComments(text: string): string[] {
+    const result: string[] = [];
+    let state = CommentScannerState.Text;
+    let pos = 0;
+    function readSingleLineComment() {
+      const startPos = pos;
+      while(pos < text.length && text[pos] !== '\n') {
+        pos++;
+      }
+      return text.substring(startPos, pos);
+    }
+    function readMultiLineComment() {
+      const startPos = pos;
+      while(pos < text.length) {
+        if(pos < text.length - 1 && text[pos] === '*' && text[pos + 1] === '/') {
+          pos += 2;
+          break;
+        }
+        pos++;
+      }
+      return text.substring(startPos, pos);
+    }
+
+    function skipString(lastChar: string) {
+      pos++;
+      while(pos < text.length) {
+        if(text[pos] === lastChar) {
+          pos++;
+          return;
+        } else if(text[pos] === '\\') {
+          pos+=2;
+          continue;
+        }
+        pos++;
+      }
+    }
+
+
+    while(pos < text.length - 1) {
+      if(text[pos] === '/' && text[pos + 1] === '/') {
+        result.push(readSingleLineComment());
+      } else if(text[pos] === '/' && text[pos + 1] === '*') {
+        result.push(readMultiLineComment());
+      } else if(text[pos] === "'") {
+        skipString("'");
+      } else if(text[pos] === '"') {
+        skipString('"');
+      } else if(text[pos] === '`') {
+        skipString('`');
+      } else if(text[pos] == '/') {
+        skipString('/');
+      } {
+        pos++;
+      }
+
+    }
+    return result;
+  }
+}
diff --git a/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts b/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts
new file mode 100644
index 0000000..b1a4320
--- /dev/null
+++ b/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts
@@ -0,0 +1,270 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as ts from 'typescript';
+import * as codeUtils from './codeUtils';
+import {LegacyLifecycleMethodName, LegacyLifecycleMethodsArray} from '../funcToClassConversion/polymerComponentParser';
+import {SyntaxKind} from 'typescript';
+
+enum PolymerClassMemberType {
+  IsAccessor,
+  Constructor,
+  PolymerPropertiesAccessor,
+  PolymerObserversAccessor,
+  Method,
+  ExistingLifecycleMethod,
+  NewLifecycleMethod,
+  GetAccessor,
+}
+
+type PolymerClassMember = PolymerClassIsAccessor | PolymerClassConstructor | PolymerClassExistingLifecycleMethod | PolymerClassNewLifecycleMethod | PolymerClassSimpleMember;
+
+interface PolymerClassExistingLifecycleMethod {
+  member: ts.MethodDeclaration;
+  memberType: PolymerClassMemberType.ExistingLifecycleMethod;
+  name: string;
+  lifecycleOrder: number;
+  originalPos: number;
+}
+
+interface PolymerClassNewLifecycleMethod {
+  member: ts.MethodDeclaration;
+  memberType: PolymerClassMemberType.NewLifecycleMethod;
+  name: string;
+  lifecycleOrder: number;
+  originalPos: -1
+}
+
+interface PolymerClassIsAccessor {
+  member: ts.GetAccessorDeclaration;
+  memberType: PolymerClassMemberType.IsAccessor;
+  originalPos: -1
+}
+
+interface PolymerClassConstructor {
+  member: ts.ConstructorDeclaration;
+  memberType: PolymerClassMemberType.Constructor;
+  originalPos: -1
+}
+
+interface PolymerClassSimpleMember {
+  memberType: PolymerClassMemberType.PolymerPropertiesAccessor | PolymerClassMemberType.PolymerObserversAccessor | PolymerClassMemberType.Method | PolymerClassMemberType.GetAccessor;
+  member: ts.ClassElement;
+  originalPos: number;
+}
+
+export interface PolymerClassBuilderResult {
+  classDeclaration: ts.ClassDeclaration;
+  generatedComments: string[];
+}
+
+export class PolymerClassBuilder {
+  private readonly members: PolymerClassMember[] = [];
+  public readonly constructorStatements: ts.Statement[] = [];
+  private baseType: ts.ExpressionWithTypeArguments | undefined;
+  private classJsDocComments: string[];
+
+  public constructor(public readonly className: string) {
+    this.classJsDocComments = [];
+  }
+
+  public addIsAccessor(accessor: ts.GetAccessorDeclaration) {
+    this.members.push({
+      member: accessor,
+      memberType: PolymerClassMemberType.IsAccessor,
+      originalPos: -1
+    });
+  }
+
+  public addPolymerPropertiesAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
+    this.members.push({
+      member: accessor,
+      memberType: PolymerClassMemberType.PolymerPropertiesAccessor,
+      originalPos: originalPos
+    });
+  }
+
+  public addPolymerObserversAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
+    this.members.push({
+      member: accessor,
+      memberType: PolymerClassMemberType.PolymerObserversAccessor,
+      originalPos: originalPos
+    });
+  }
+
+
+  public addClassFieldInitializer(name: string | ts.Identifier, initializer: ts.Expression) {
+    const assignment = ts.createAssignment(ts.createPropertyAccess(ts.createThis(), name), initializer);
+    this.constructorStatements.push(codeUtils.addNewLineAfterNode(ts.createExpressionStatement(assignment)));
+  }
+  public addMethod(originalPos: number, method: ts.MethodDeclaration) {
+    this.members.push({
+      member: method,
+      memberType: PolymerClassMemberType.Method,
+      originalPos: originalPos
+    });
+  }
+
+  public addGetAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
+    this.members.push({
+      member: accessor,
+      memberType: PolymerClassMemberType.GetAccessor,
+      originalPos: originalPos
+    });
+  }
+
+  public addLifecycleMethod(name: LegacyLifecycleMethodName, originalPos: number, method: ts.MethodDeclaration) {
+    const lifecycleOrder = LegacyLifecycleMethodsArray.indexOf(name);
+    if(lifecycleOrder < 0) {
+      throw new Error(`Invalid lifecycle name`);
+    }
+    if(originalPos >= 0) {
+      this.members.push({
+        member: method,
+        memberType: PolymerClassMemberType.ExistingLifecycleMethod,
+        originalPos: originalPos,
+        name: name,
+        lifecycleOrder: lifecycleOrder
+      })
+    } else {
+      this.members.push({
+        member: method,
+        memberType: PolymerClassMemberType.NewLifecycleMethod,
+        name: name,
+        lifecycleOrder: lifecycleOrder,
+        originalPos: -1
+      })
+    }
+  }
+
+  public setBaseType(type: ts.ExpressionWithTypeArguments) {
+    if(this.baseType) {
+      throw new Error("Class can have only one base type");
+    }
+    this.baseType = type;
+  }
+
+  public build(): PolymerClassBuilderResult {
+    let heritageClauses: ts.HeritageClause[] = [];
+    if (this.baseType) {
+      const extendClause = ts.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [this.baseType]);
+      heritageClauses.push(extendClause);
+    }
+    const finalMembers: PolymerClassMember[] = [];
+    const isAccessors = this.members.filter(member => member.memberType === PolymerClassMemberType.IsAccessor);
+    if(isAccessors.length !== 1) {
+      throw new Error("Class must have exactly one 'is'");
+    }
+    finalMembers.push(isAccessors[0]);
+    const constructorMember = this.createConstructor();
+    if(constructorMember) {
+      finalMembers.push(constructorMember);
+    }
+
+    const newLifecycleMethods: PolymerClassNewLifecycleMethod[] = [];
+    this.members.forEach(member => {
+      if(member.memberType === PolymerClassMemberType.NewLifecycleMethod) {
+        newLifecycleMethods.push(member);
+      }
+    });
+
+    const methodsWithKnownPosition = this.members.filter(member => member.originalPos >= 0);
+    methodsWithKnownPosition.sort((a, b) => a.originalPos - b.originalPos);
+
+    finalMembers.push(...methodsWithKnownPosition);
+
+
+    for(const newLifecycleMethod of newLifecycleMethods) {
+      //Number of methods is small - use brute force solution
+      let closestNextIndex = -1;
+      let closestNextOrderDiff: number = LegacyLifecycleMethodsArray.length;
+      let closestPrevIndex = -1;
+      let closestPrevOrderDiff: number = LegacyLifecycleMethodsArray.length;
+      for (let i = 0; i < finalMembers.length; i++) {
+        const member = finalMembers[i];
+        if (member.memberType !== PolymerClassMemberType.NewLifecycleMethod && member.memberType !== PolymerClassMemberType.ExistingLifecycleMethod) {
+          continue;
+        }
+        const orderDiff = member.lifecycleOrder - newLifecycleMethod.lifecycleOrder;
+        if (orderDiff > 0) {
+          if (orderDiff < closestNextOrderDiff) {
+            closestNextIndex = i;
+            closestNextOrderDiff = orderDiff;
+          }
+        } else if (orderDiff < 0) {
+          if (orderDiff < closestPrevOrderDiff) {
+            closestPrevIndex = i;
+            closestPrevOrderDiff = orderDiff;
+          }
+        }
+      }
+      let insertIndex;
+      if (closestNextIndex !== -1 || closestPrevIndex !== -1) {
+        insertIndex = closestNextOrderDiff < closestPrevOrderDiff ?
+            closestNextIndex : closestPrevIndex + 1;
+      } else {
+        insertIndex = Math.max(
+            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.Constructor),
+            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.IsAccessor),
+            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.PolymerPropertiesAccessor),
+            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.PolymerObserversAccessor),
+        );
+        if(insertIndex < 0) {
+          insertIndex = finalMembers.length;
+        } else {
+          insertIndex++;//Insert after
+        }
+      }
+      finalMembers.splice(insertIndex, 0, newLifecycleMethod);
+    }
+    //Asserts about finalMembers
+    const nonConstructorMembers = finalMembers.filter(m => m.memberType !== PolymerClassMemberType.Constructor);
+
+    if(nonConstructorMembers.length !== this.members.length) {
+      throw new Error(`Internal error! Some methods are missed`);
+    }
+    let classDeclaration = ts.createClassDeclaration(undefined, undefined, this.className, undefined, heritageClauses, finalMembers.map(m => m.member))
+    const generatedComments: string[] = [];
+    if(this.classJsDocComments.length > 0) {
+      const commentContent = '*\n' + this.classJsDocComments.map(line => `* ${line}`).join('\n') + '\n';
+      classDeclaration = ts.addSyntheticLeadingComment(classDeclaration, ts.SyntaxKind.MultiLineCommentTrivia, commentContent, true);
+      generatedComments.push(`/*${commentContent}*/`);
+    }
+    return {
+      classDeclaration,
+      generatedComments,
+    };
+
+  }
+
+  private createConstructor(): PolymerClassConstructor | null {
+    if(this.constructorStatements.length === 0) {
+      return null;
+    }
+    let superCall: ts.CallExpression = ts.createCall(ts.createSuper(), [], []);
+    const superCallExpression = ts.createExpressionStatement(superCall);
+    const statements = [superCallExpression, ...this.constructorStatements];
+    const constructorDeclaration = ts.createConstructor([], [], [], ts.createBlock(statements, true));
+
+    return {
+      memberType: PolymerClassMemberType.Constructor,
+      member: constructorDeclaration,
+      originalPos: -1
+    };
+  }
+
+  public addClassJSDocComments(lines: string[]) {
+    this.classJsDocComments.push(...lines);
+  }
+}
\ No newline at end of file
diff --git a/java/com/google/gerrit/server/notedb/NoteDbTable.java b/tools/polygerrit-updater/src/utils/unexpectedValue.ts
similarity index 65%
copy from java/com/google/gerrit/server/notedb/NoteDbTable.java
copy to tools/polygerrit-updater/src/utils/unexpectedValue.ts
index e299fdf..690c283 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/tools/polygerrit-updater/src/utils/unexpectedValue.ts
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,19 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
-
-public enum NoteDbTable {
-  ACCOUNTS,
-  GROUPS,
-  CHANGES;
-
-  public String key() {
-    return name().toLowerCase();
-  }
-
-  @Override
-  public String toString() {
-    return key();
-  }
+export function unexpectedValue<T>(x: T): never {
+  throw new Error(`Unexpected value '${x}'`);
 }
diff --git a/tools/polygerrit-updater/tsconfig.json b/tools/polygerrit-updater/tsconfig.json
new file mode 100644
index 0000000..37ff1b2
--- /dev/null
+++ b/tools/polygerrit-updater/tsconfig.json
@@ -0,0 +1,67 @@
+{
+  "compilerOptions": {
+    /* Basic Options */
+    // "incremental": true,                   /* Enable incremental compilation */
+    "target": "es2015",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+    // "lib": [],                             /* Specify library files to be included in the compilation. */
+    // "allowJs": true,                       /* Allow javascript files to be compiled. */
+    // "checkJs": true,                       /* Report errors in .js files. */
+    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
+    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
+    "sourceMap": true,                     /* Generates corresponding '.map' file. */
+    // "outFile": "./",                       /* Concatenate and emit output to single file. */
+    "outDir": "./js",                        /* Redirect output structure to the directory. */
+    "rootDir": ".",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+    // "composite": true,                     /* Enable project compilation */
+    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
+    // "removeComments": true,                /* Do not emit comments to output. */
+    // "noEmit": true,                        /* Do not emit outputs. */
+    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
+    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+
+    /* Strict Type-Checking Options */
+    "strict": true,                           /* Enable all strict type-checking options. */
+    "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
+    // "strictNullChecks": true,              /* Enable strict null checks. */
+    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
+    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+    // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
+    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
+    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */
+
+    /* Additional Checks */
+    // "noUnusedLocals": true,                /* Report errors on unused locals. */
+    // "noUnusedParameters": true,            /* Report errors on unused parameters. */
+    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
+    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
+
+    /* Module Resolution Options */
+    "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
+    "paths": {
+      "*": [ "node_modules/*" ]
+    },                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
+    // "typeRoots": [],                       /* List of folders to include type definitions from. */
+    // "types": [],                           /* Type declaration files to be included in compilation. */
+    "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+    "esModuleInterop": true                   /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
+    // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */
+
+    /* Source Map Options */
+    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
+    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+
+    /* Experimental Options */
+    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
+    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
+
+  },
+  "include": ["./src/**/*"]
+}
diff --git a/version.bzl b/version.bzl
index d758905..fb1e5ca 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.1.3-SNAPSHOT"
+GERRIT_VERSION = "3.2.0-SNAPSHOT"